Keep the cell draft in its own workbook; Switch Windows lists open workbooks

A cell being typed when File > New ran (macOS menu, control channel) stayed
open over the new workbook, and Enter wrote it there; the first workbook
stayed clean and nothing in the UI reached it.

- Every file.* and window command (window.activate, view.newWindow,
  view.hideWindow, view.unhideWindow, view.switchWindows with a doc) first
  commits the cell and text-box drafts as Enter would, or drops a draft its
  workbook refuses, still reporting why. Finder, argv and dropped opens go
  through file.open and get the same.
- The cell editor records its workbook's uid; the grid draws a draft only
  over that workbook, and a commit always goes into it.
- view.switchWindows takes a `doc` uid to bring that workbook forward and
  lists index, doc, title, dirty, active and hidden. Menu-style calls with
  no doc open the View tab's new Switch Windows dropdown.
- macOS: the Window menu lists the open workbooks (unsaved bullet, active
  check), and Quit closes the window so unsaved workbooks are asked about.
This commit is contained in:
aiongg
2026-10-22 05:01:47 +00:00
parent 6f10378b17
commit 1794c389e5
10 changed files with 337 additions and 39 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ impl eframe::App for App {
if self.1.is_none() && std::env::var_os("GRIDCRAFT_NO_NATIVE_MENU").is_none() {
self.1 = Some(native_menu::NativeMenu::install(ctx));
}
if let Some(m) = &self.1 {
if let Some(m) = &mut self.1 {
m.poll(&mut self.0, ctx);
}
apple_events::poll(&self.2, &mut self.0, ctx);
+62 -6
View File
@@ -4,7 +4,7 @@
use std::str::FromStr;
use std::sync::mpsc::{Receiver, channel};
use gridcraft_ui_egui::SheetApp;
use gridcraft_ui_egui::{OpenWindow, SheetApp};
use muda::accelerator::Accelerator;
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use serde_json::{Value, json};
@@ -168,7 +168,16 @@ fn tree() -> Vec<(&'static str, Vec<Entry>)> {
("Calculate Now", "formulas.calculateNow", None),
],
),
("Window", vec![("New Window", "view.newWindow", None), ("Next Sheet", "sheet.next", None), ("Previous Sheet", "sheet.previous", None)]),
// The open workbooks follow the separator (`NativeMenu::sync_windows`).
(
"Window",
vec![
("New Window", "view.newWindow", None),
("Next Sheet", "sheet.next", None),
("Previous Sheet", "sheet.previous", None),
("-", "", None),
],
),
(
"Help",
vec![
@@ -185,8 +194,19 @@ pub struct NativeMenu {
_menu: Menu,
events: Receiver<MenuEvent>,
theme_items: Vec<(&'static str, CheckMenuItem)>,
window_menu: Option<Submenu>,
/// The Window menu's workbook items and what they last showed.
window_items: Vec<CheckMenuItem>,
windows: Vec<OpenWindow>,
}
/// Menu id of the Window menu item that brings workbook `doc` forward.
const WINDOW_ITEM: &str = "window:doc:";
/// The Quit item: closing the window asks about unsaved workbooks first, which AppKit's own
/// Quit (terminate:) would skip.
const QUIT: &str = "app.quit";
fn select_theme(app: &mut SheetApp, id: &str) -> bool {
let mode = match id {
"theme:system" => "system",
@@ -212,7 +232,7 @@ impl NativeMenu {
&PredefinedMenuItem::hide_others(None),
&PredefinedMenuItem::show_all(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::quit(None),
&MenuItem::with_id(QUIT, "Quit GridCraft", true, Accelerator::from_str("CMD+Q").ok()),
]);
let _ = menu.append(&app_menu);
let mut theme_items = Vec::new();
@@ -247,7 +267,7 @@ impl NativeMenu {
// NSApp's windowsMenu is where macOS adds Move & Resize, Fill and Center, the targets of the
// system tiling shortcuts (Fn+Ctrl+arrows, Fn+F). Only once the menu is NSApp's: muda looks
// the submenu up in the installed main menu.
if let Some(window) = window_menu {
if let Some(window) = &window_menu {
window.set_as_windows_menu_for_nsapp();
}
let (tx, rx) = channel();
@@ -256,12 +276,47 @@ impl NativeMenu {
let _ = tx.send(e);
ctx.request_repaint();
}));
NativeMenu { _menu: menu, events: rx, theme_items }
NativeMenu { _menu: menu, events: rx, theme_items, window_menu, window_items: Vec::new(), windows: Vec::new() }
}
pub fn poll(&self, app: &mut SheetApp, ctx: &egui::Context) {
/// Keeps the Window menu's list of open workbooks (title, unsaved bullet, active check) in
/// step with the session.
fn sync_windows(&mut self, app: &SheetApp) {
let windows = app.open_windows();
if windows == self.windows {
return;
}
let Some(menu) = &self.window_menu else { return };
let same_docs = windows.len() == self.windows.len() && windows.iter().zip(&self.windows).all(|(a, b)| a.doc == b.doc);
if same_docs {
for (item, w) in self.window_items.iter().zip(&windows) {
item.set_text(w.label());
item.set_checked(w.active);
}
} else {
for item in self.window_items.drain(..) {
let _ = menu.remove(&item);
}
for w in &windows {
let item = CheckMenuItem::with_id(format!("{WINDOW_ITEM}{}", w.doc), w.label(), true, w.active, None);
let _ = menu.append(&item);
self.window_items.push(item);
}
}
self.windows = windows;
}
pub fn poll(&mut self, app: &mut SheetApp, ctx: &egui::Context) {
while let Ok(ev) = self.events.try_recv() {
let id = ev.id.as_ref().to_string();
if id == QUIT {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
continue;
}
if let Some(doc) = id.strip_prefix(WINDOW_ITEM).and_then(|d| d.parse::<u64>().ok()) {
app.run_or_alert("view.switchWindows", json!({"doc": doc}));
continue;
}
// Text selection belongs to the inline editor, not the worksheet clipboard/undo.
if app.text_box_editor.is_some() {
let clipboard = match id.as_str() {
@@ -321,6 +376,7 @@ impl NativeMenu {
item.set_checked(checked);
}
}
self.sync_windows(app);
}
}
+25 -3
View File
@@ -145,7 +145,15 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "view.arrangeAll", "Arrange All", ["View", "Window"], None, "{}", has_doc, |_, _| ok()),
cmd!(noundo "view.hideWindow", "Hide", ["View", "Window"], None, "{}", has_doc, hide_window),
cmd!(noundo "view.unhideWindow", "Unhide…", ["View", "Window"], None, "{index?}", has_doc, unhide_window),
cmd!(query "view.switchWindows", "Switch Windows", ["View", "Window"], None, "{} → open windows", always, switch_windows),
cmd!(
noundo "view.switchWindows",
"Switch Windows",
["View", "Window"],
None,
"{doc?} → open workbooks [{index, doc, title, dirty, active, hidden}]; with `doc` (a workbook's `doc` id from the list) makes that workbook active first",
always,
switch_windows
),
cmd!(noundo "view.ruler", "Ruler", ["View", "Show"], None, "{on?} → {on}: rulers in Page Layout view (an application option)", has_doc, ruler),
cmd!(query "view.macros", "Macros", ["View", "Macros"], None, "{} → scripts", has_doc, all_scripts),
// Table tools
@@ -808,12 +816,26 @@ fn unhide_window(s: &mut Session, p: &Json) -> Result<Json> {
ok()
}
fn switch_windows(s: &mut Session, _: &Json) -> Result<Json> {
fn switch_windows(s: &mut Session, p: &Json) -> Result<Json> {
if let Some(doc) = p.get("doc").filter(|v| !v.is_null()) {
let i = doc.as_u64().and_then(|uid| s.document_index(uid)).ok_or_else(|| bad("view.switchWindows", "no open workbook has that `doc` id"))?;
s.hidden_windows.retain(|x| *x != i);
s.set_active(i);
}
Ok(Json::Array(
s.documents()
.iter()
.enumerate()
.map(|(i, d)| json!({"index": i, "title": d.display_title(), "active": i == s.active_index(), "hidden": s.hidden_windows.contains(&i)}))
.map(|(i, d)| {
json!({
"index": i,
"doc": d.uid,
"title": d.display_title(),
"dirty": d.is_dirty(),
"active": i == s.active_index(),
"hidden": s.hidden_windows.contains(&i),
})
})
.collect(),
))
}
+6
View File
@@ -106,6 +106,8 @@ pub struct DocState {
pub path: Option<String>,
pub saved: Arc<Workbook>,
pub revision: u64,
/// Identifies this open workbook for the rest of the session: it never changes while the
/// workbook is open (saving, renaming, reordering) and is never reused for another one.
pub uid: u64,
pub title: String,
/// Per-sheet selection memory.
@@ -336,6 +338,10 @@ impl Session {
pub fn active(&self) -> Option<&DocState> {
self.docs.get(self.active)
}
/// Where the open workbook with this [`DocState::uid`] is in [`Session::documents`].
pub fn document_index(&self, uid: u64) -> Option<usize> {
self.docs.iter().position(|d| d.uid == uid)
}
pub fn active_mut(&mut self) -> Option<&mut DocState> {
self.docs.get_mut(self.active)
}
+31
View File
@@ -4459,3 +4459,34 @@ fn html_export_without_pictures_or_charts_has_no_fixed_geometry() {
let html = crate::io::to_html(&s.doc().unwrap().wb, 0).unwrap();
assert!(!html.contains("<colgroup>") && !html.contains("table-layout") && !html.contains("<svg") && !html.contains("<img"), "{html}");
}
#[test]
fn switch_windows_lists_workbooks_by_stable_id_and_switches_to_one() {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "first"})).unwrap();
let book1 = s.doc().unwrap().uid;
s.execute("file.new", json!({})).unwrap();
s.execute("file.new", json!({})).unwrap();
let book3 = s.doc().unwrap().uid;
let list = s.execute("view.switchWindows", json!({})).unwrap();
let rows = list.as_array().unwrap();
assert_eq!(rows.len(), 3);
assert_eq!((rows[0]["doc"].as_u64(), rows[0]["title"].as_str(), rows[0]["dirty"].as_bool()), (Some(book1), Some("Book1"), Some(true)));
assert_eq!((rows[2]["dirty"].as_bool(), rows[2]["active"].as_bool()), (Some(false), Some(true)));
// Closing a workbook shifts the indexes but not the ids.
s.execute("window.activate", json!({"index": 1})).unwrap();
s.execute("file.close", json!({"force": true})).unwrap();
assert_eq!(s.document_index(book3), Some(1));
let list = s.execute("view.switchWindows", json!({"doc": book1})).unwrap();
assert_eq!(s.doc().unwrap().uid, book1);
assert_eq!(v(&s, "A1"), Value::text("first"));
assert_eq!(list[0]["active"].as_bool(), Some(true));
s.execute("file.save", json!({"path": std::env::temp_dir().join(format!("gridcraft-uid-{}.xlsx", std::process::id()))})).unwrap();
assert_eq!(s.doc().unwrap().uid, book1, "saving keeps the id");
let _ = std::fs::remove_file(std::env::temp_dir().join(format!("gridcraft-uid-{}.xlsx", std::process::id())));
assert!(s.execute("view.switchWindows", json!({"doc": book3 + 1000})).is_err());
assert!(s.execute("view.switchWindows", json!({"doc": "Book1"})).is_err());
assert_eq!(s.doc().unwrap().uid, book1, "a bad id leaves the active workbook alone");
}
+9 -6
View File
@@ -18,6 +18,8 @@ pub const REF_COLORS: [Color32; 8] = [
#[derive(Clone, Debug)]
pub struct EditState {
/// The workbook (`DocState::uid`) the draft belongs to: it is drawn and committed only there.
pub doc: u64,
pub sheet: usize,
/// Syntax of this edit, captured when it starts (prevents a language switch reinterpreting it).
pub locale: gridcraft_engine::formula::FormulaLocale,
@@ -46,9 +48,10 @@ pub struct EditState {
}
impl EditState {
pub fn new(sheet: usize, cell: CellRef, text: String, enter_mode: bool, from_formula_bar: bool) -> EditState {
pub fn new(doc: u64, sheet: usize, cell: CellRef, text: String, enter_mode: bool, from_formula_bar: bool) -> EditState {
let caret = text.chars().count();
EditState {
doc,
sheet,
locale: Default::default(),
cell,
@@ -365,7 +368,7 @@ mod tests {
#[test]
fn point_mode() {
let mut e = EditState::new(0, CellRef::new(0, 0), "=SUM(".into(), true, false);
let mut e = EditState::new(1, 0, CellRef::new(0, 0), "=SUM(".into(), true, false);
assert!(e.can_point());
e.insert_ref("A1");
assert_eq!(e.text, "=SUM(A1");
@@ -375,19 +378,19 @@ mod tests {
e.caret += 1;
e.point = None;
assert!(e.can_point());
let e2 = EditState::new(0, CellRef::new(0, 0), "hello".into(), true, false);
let e2 = EditState::new(1, 0, CellRef::new(0, 0), "hello".into(), true, false);
assert!(!e2.can_point());
}
#[test]
fn autocomplete_and_hints() {
let names: Vec<String> = ["SUM", "SUMIF", "SUMPRODUCT", "AVERAGE"].iter().map(|s| s.to_string()).collect();
let mut e = EditState::new(0, CellRef::new(0, 0), "=su".into(), true, false);
let mut e = EditState::new(1, 0, CellRef::new(0, 0), "=su".into(), true, false);
e.update_autocomplete(&names);
assert_eq!(e.autocomplete.len(), 3);
assert!(e.accept_autocomplete());
assert_eq!(e.text, "=SUM(");
let e = EditState::new(0, CellRef::new(0, 0), "=IF(A1>0,SUM(B1,".into(), true, false);
let e = EditState::new(1, 0, CellRef::new(0, 0), "=IF(A1>0,SUM(B1,".into(), true, false);
assert_eq!(e.current_function(), Some(("SUM".into(), 1)));
}
@@ -423,7 +426,7 @@ mod tests {
#[test]
fn inserting_a_union_keeps_each_reference_separately_pointed() {
let mut edit = EditState::new(0, CellRef::new(0, 0), "=SUM(".into(), true, false);
let mut edit = EditState::new(1, 0, CellRef::new(0, 0), "=SUM(".into(), true, false);
edit.insert_ref("C:C");
edit.append_ref("E:E");
assert_eq!(edit.text, "=SUM(C:C,E:E");
+2 -1
View File
@@ -1744,6 +1744,7 @@ pub(crate) fn paint_selection(
// Editing a formula: outline its references instead of the selection.
if let Some(ed) = &app.editor
&& ed.is_formula()
&& app.session.active().is_some_and(|d| d.uid == ed.doc)
{
let origin = app.session.active().and_then(|d| d.wb.sheet(ed.sheet)).map_or(sh.name.as_str(), |o| o.name.as_str());
for (_, _, r, sheet, idx) in crate::editor::formula_refs(&ed.text) {
@@ -3045,7 +3046,7 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, wb: &Workbook) {
let Some(ed) = app.editor.as_ref() else { return };
if ed.sheet != wb.active_sheet {
if app.view_key() != Some((ed.doc, ed.sheet)) {
return;
}
let cell = ed.cell;
+105 -22
View File
@@ -166,6 +166,23 @@ pub struct Notice {
pub action: Option<(String, String)>,
}
/// One open workbook, as View › Switch Windows and the macOS Window menu list it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OpenWindow {
/// The workbook's `DocState::uid`, the `doc` param of `view.switchWindows`.
pub doc: u64,
pub title: String,
pub dirty: bool,
pub active: bool,
}
impl OpenWindow {
/// The title, with a bullet after it while the workbook has unsaved changes.
pub fn label(&self) -> String {
if self.dirty { format!("{} •", self.title) } else { self.title.clone() }
}
}
/// Host services the platform shell provides (file pickers, opening URLs).
#[derive(Default)]
pub struct Services {
@@ -296,6 +313,9 @@ pub struct SheetApp {
/// Every unsaved workbook has been answered for quitting (or `app.quit` asked): the next
/// window close goes through.
pub allow_close: bool,
/// View › Switch Windows opens its list on the next frame the View tab draws (asked for by a
/// key tip, the command search or a menu, possibly while another tab was showing).
pub(crate) open_switch_windows: bool,
}
impl SheetApp {
@@ -342,6 +362,7 @@ impl SheetApp {
tab_run: None,
started: now_ms(),
allow_close: false,
open_switch_windows: false,
}
}
@@ -387,30 +408,30 @@ impl SheetApp {
}
fn run_command(&mut self, id: &str, params: Json) -> Result<Json, String> {
let switches_window = id.starts_with("window.")
|| matches!(id, "view.newWindow" | "view.hideWindow" | "view.unhideWindow")
|| (id == "view.switchWindows" && params.get("doc").is_some_and(|d| !d.is_null()));
if id.starts_with("file.") || switches_window {
self.settle_drafts();
}
if matches!(id, "file.save" | "file.saveAs" | "file.saveBytes") {
self.store_view_state();
}
if id.starts_with("file.")
|| matches!(
id,
"window.activate"
| "sheet.activate"
| "sheet.next"
| "sheet.previous"
| "sheet.move"
| "sheet.hide"
| "sheet.unhide"
| "view.newWindow"
| "view.hideWindow"
| "view.unhideWindow"
| "home.insertSheet"
| "home.deleteSheet"
| "edit.undo"
| "edit.redo"
| "object.delete"
| "object.move"
)
{
if matches!(
id,
"sheet.activate"
| "sheet.next"
| "sheet.previous"
| "sheet.move"
| "sheet.hide"
| "sheet.unhide"
| "home.insertSheet"
| "home.deleteSheet"
| "edit.undo"
| "edit.redo"
| "object.delete"
| "object.move"
) {
self.commit_text_box_edit()?;
}
if let Some(r) = self.run_ui_command(id, &params) {
@@ -546,6 +567,14 @@ impl SheetApp {
if id == "insert.recommendedCharts" && params.get("type").is_none() {
return self.open_dialog("recommendedCharts", json!({}));
}
// Switch Windows with no workbook named (key tip, command search) opens the View tab's
// list to choose from; programmatic callers of `run` get the list back.
if id == "view.switchWindows" && params.get("doc").is_none_or(Json::is_null) {
self.ui.ribbon_tab = "View".into();
self.ui.ribbon_collapsed = false;
self.open_switch_windows = true;
return;
}
if let Err(e) = self.run(id, params) {
self.alert(e);
}
@@ -882,6 +911,17 @@ impl SheetApp {
}
}
/// The open workbooks in order, for the Switch Windows lists.
pub fn open_windows(&self) -> Vec<OpenWindow> {
let active = self.session.active_index();
self.session
.documents()
.iter()
.enumerate()
.map(|(i, d)| OpenWindow { doc: d.uid, title: d.display_title(), dirty: d.is_dirty(), active: i == active })
.collect()
}
pub fn view_key(&self) -> Option<(u64, usize)> {
self.session.active().map(|d| (d.uid, d.wb.active_sheet))
}
@@ -961,7 +1001,7 @@ impl SheetApp {
Some(t) => (t, true),
None => (current.clone(), false),
};
let mut ed = editor::EditState::new(d.wb.active_sheet, at, text, replace, from_formula_bar);
let mut ed = editor::EditState::new(d.uid, d.wb.active_sheet, at, text, replace, from_formula_bar);
ed.locale = locale;
if replace && self.ui.autocomplete && !ed.is_formula() {
ed.completion = editor::column_completion(sh, at, &ed.text);
@@ -971,7 +1011,45 @@ impl SheetApp {
}
/// Commits the edit; `then` moves the selection (dr, dc) afterwards.
///
/// The draft goes into the workbook it was typed in. If another workbook is showing, that
/// workbook stays active afterwards, and a draft its own workbook refuses (validation, a bad
/// formula) is dropped rather than left open over the wrong grid.
pub fn commit_edit(&mut self, dr: i64, dc: i64, array: bool, fill_selection: bool) -> bool {
let Some(doc) = self.editor.as_ref().map(|ed| ed.doc) else { return true };
let shown = self.session.active().map(|d| d.uid);
if shown == Some(doc) {
return self.commit_edit_here(dr, dc, array, fill_selection);
}
let Some(owner) = self.session.document_index(doc) else {
self.cancel_edit();
return true;
};
let back = self.session.active_index();
self.session.set_active(owner);
if !self.commit_edit_here(dr, dc, array, fill_selection) {
self.cancel_edit();
}
self.session.set_active(back);
true
}
/// Settles the cell and text-box drafts before a command that can show another workbook or
/// write this one to a file: each is committed as Enter would, or dropped when it can't be
/// (the refusal is still reported), so a draft never lands in another workbook.
fn settle_drafts(&mut self) {
if !self.commit_edit(0, 0, false, false) {
self.cancel_edit();
}
if let Err(e) = self.commit_text_box_edit() {
self.cancel_text_box_edit();
if self.message.is_none() {
self.message = Some(("Text Box".into(), clean_error(&e)));
}
}
}
fn commit_edit_here(&mut self, dr: i64, dc: i64, array: bool, fill_selection: bool) -> bool {
let Some(ed) = self.editor.take() else { return true };
self.session.mode = gridcraft_engine::Mode::Ready;
let text = match &ed.completion {
@@ -1276,6 +1354,11 @@ impl SheetApp {
}
let t0 = now_ms();
text_box::before_ui(self, &ctx);
// Another workbook came forward without going through a command (which settles drafts
// first): the draft goes into its own workbook instead of floating over this one.
if self.editor.as_ref().is_some_and(|ed| self.session.active().map(|d| d.uid) != Some(ed.doc)) {
self.commit_edit(0, 0, false, false);
}
// The language the widgets translate with this frame (see `i18n::current`).
i18n::set_current(&ctx, self.ui.language);
let t = theme::Tokens::get(&ctx);
+19
View File
@@ -2336,6 +2336,7 @@ fn view(app: &mut SheetApp, ui: &mut Ui) {
],
);
});
switch_windows(app, ui);
ui.vertical(|ui| {
ui.label(lang.tr("Display theme"));
let mode = app.ui.theme_mode();
@@ -2391,6 +2392,24 @@ fn view(app: &mut SheetApp, ui: &mut Ui) {
});
}
/// View › Switch Windows: the open workbooks, the active one checked and unsaved ones marked;
/// choosing one brings it forward.
fn switch_windows(app: &mut SheetApp, ui: &mut Ui) {
let anchor = big_button(ui, Icon::Book, "Switch\nWindows", "Switch Windows", true);
anchor.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), "Switch Windows"));
control_keytip(app, ui, &anchor, "view.switchWindows");
let forced = std::mem::take(&mut app.open_switch_windows);
egui::Popup::menu(&anchor).open_memory(popup_command(forced, anchor.clicked())).show(|ui| {
for w in app.open_windows() {
let label = format!("{}{}", if w.active { "✓ " } else { " " }, w.label());
if ui.add(egui::Button::selectable(w.active, label).min_size(vec2(220.0, 22.0))).clicked() {
act(app, "view.switchWindows", json!({"doc": w.doc}));
ui.close();
}
}
});
}
fn automate(app: &mut SheetApp, ui: &mut Ui) {
if big_button(ui, Icon::Script, "Macros", "Macros and scripts in this workbook (Alt+F8)", false).clicked() {
app.open_dialog("macros", json!({}));
+77
View File
@@ -289,6 +289,83 @@ fn closing_the_window_can_save_first_and_a_saved_workbook_closes_at_once() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn quitting_with_one_of_two_workbooks_unsaved_asks_once() {
let mut h = harness(Services::default());
h.state_mut().run("cell.set", serde_json::json!({"cell": "A1", "input": "42"})).unwrap();
h.state_mut().run("file.new", serde_json::json!({})).unwrap();
h.run_steps(2);
let sent = request_window_close(&mut h);
assert!(sent.contains(&egui::ViewportCommand::CancelClose), "{sent:?}");
assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges"));
assert_eq!(h.state().session.active_index(), 0, "the unsaved workbook comes forward");
let sent = click_commands(&mut h, "Cancel");
assert!(!sent.contains(&egui::ViewportCommand::Close), "Cancel stops the quit");
assert_eq!(h.state().session.documents().len(), 2, "Cancel keeps both workbooks");
request_window_close(&mut h);
let sent = click_commands(&mut h, "Don't Save");
assert!(sent.contains(&egui::ViewportCommand::Close), "the saved workbook isn't asked about: {sent:?}");
assert!(h.state().dialog.is_none());
}
/// Book1 has a cell being typed when File › New runs (the macOS menu runs the command directly).
fn typing_in_book1_then_new(h: &mut egui_kittest::Harness<'static, SheetApp>) -> u64 {
h.input_mut().events.push(egui::Event::Text("hello".into()));
h.run_steps(2);
assert!(h.state().editor.is_some());
let book1 = h.state().session.doc().unwrap().uid;
h.state_mut().run_or_alert("file.new", serde_json::json!({}));
h.run_steps(3);
book1
}
fn a1(h: &egui_kittest::Harness<'static, SheetApp>, doc: usize) -> Value {
h.state().session.documents()[doc].wb.active().unwrap().value(CellRef::new(0, 0))
}
#[test]
fn new_workbook_keeps_the_typed_cell_in_the_first_and_switch_windows_brings_it_back() {
let mut h = harness(Services::default());
let book1 = typing_in_book1_then_new(&mut h);
assert!(h.state().editor.is_none(), "the draft doesn't follow into Book2");
assert_eq!(h.state().session.documents().len(), 2);
assert_eq!(h.state().session.active_index(), 1);
assert_eq!(a1(&h, 0), Value::text("hello"), "the draft is committed into Book1, as Enter would");
assert!(h.state().session.documents()[0].is_dirty(), "so quitting asks about Book1");
assert_eq!(a1(&h, 1), Value::Empty);
h.key_press(egui::Key::Enter);
h.run_steps(2);
assert_eq!(a1(&h, 1), Value::Empty, "Enter in Book2 writes nothing left over");
// The key tip or command search opens the View tab's list; Book1 shows its unsaved bullet.
h.state_mut().run_or_alert("view.switchWindows", serde_json::json!({}));
h.run_steps(3);
assert_eq!(h.state().ui.ribbon_tab, "View");
h.get_by_label_contains("Book1 •").click();
h.run_steps(3);
assert_eq!(h.state().session.doc().unwrap().uid, book1);
assert_eq!(h.state().session.documents().len(), 2);
assert!(h.state().message.is_none());
}
#[test]
fn a_draft_its_workbook_refuses_is_dropped_when_switching_away() {
let mut h = harness(Services::default());
let book1 = typing_in_book1_then_new(&mut h);
h.state_mut().session.execute("data.validation", serde_json::json!({"range": "A1", "type": "list", "formula1": "\"Red,Green\""})).unwrap();
h.input_mut().events.push(egui::Event::Text("Purple".into()));
h.run_steps(2);
assert!(h.state().editor.is_some());
h.state_mut().run_or_alert("view.switchWindows", serde_json::json!({"doc": book1}));
h.run_steps(2);
assert!(h.state().editor.is_none());
assert_eq!(h.state().session.doc().unwrap().uid, book1, "the switch still happens");
assert_eq!(a1(&h, 1), Value::Empty, "Book2 is unchanged");
assert!(h.state().message.is_some(), "the refusal is reported");
}
fn pdf_picker_harness(dir: &std::path::Path) -> (Arc<Mutex<Vec<String>>>, egui_kittest::Harness<'static, SheetApp>) {
let asked = Arc::new(Mutex::new(Vec::<String>::new()));
let (capture, target) = (asked.clone(), dir.to_path_buf());