diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 7c0eb04e6..8aad9aeb8 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -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); diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs index 6985af3c0..2c2866379 100644 --- a/apps/gridcraft/src/native_menu.rs +++ b/apps/gridcraft/src/native_menu.rs @@ -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)> { ("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, theme_items: Vec<(&'static str, CheckMenuItem)>, + window_menu: Option, + /// The Window menu's workbook items and what they last showed. + window_items: Vec, + windows: Vec, } +/// 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::().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); } } diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index c44094cdd..debb62eeb 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -145,7 +145,15 @@ pub fn specs() -> Vec { 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 { ok() } -fn switch_windows(s: &mut Session, _: &Json) -> Result { +fn switch_windows(s: &mut Session, p: &Json) -> Result { + 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(), )) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 10c6cb87c..aeeaa9bdb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -106,6 +106,8 @@ pub struct DocState { pub path: Option, pub saved: Arc, 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 { + self.docs.iter().position(|d| d.uid == uid) + } pub fn active_mut(&mut self) -> Option<&mut DocState> { self.docs.get_mut(self.active) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d6a99be91..07a32209f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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("") && !html.contains("table-layout") && !html.contains(" 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 = ["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"); diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index a0efbfae7..145b9f583 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -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; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index faae9bfae..001abaad5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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 { + 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, ¶ms) { @@ -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 { + 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); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 356381d66..d90b642ea 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -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!({})); diff --git a/crates/ui-egui/tests/file_menu.rs b/crates/ui-egui/tests/file_menu.rs index 047efe1b1..415e09902 100644 --- a/crates/ui-egui/tests/file_menu.rs +++ b/crates/ui-egui/tests/file_menu.rs @@ -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>>, egui_kittest::Harness<'static, SheetApp>) { let asked = Arc::new(Mutex::new(Vec::::new())); let (capture, target) = (asked.clone(), dir.to_path_buf());