diff --git a/ROADMAP.md b/ROADMAP.md index c9e746bc5..dd0255190 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -147,6 +147,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-23 | Accounting negatives in text: `$(1,234)`, `$ (5)` and `(5)%` read as negative numbers when typed, coerced by arithmetic or passed to VALUE, like Excel 365; `(-5)`, `((5))`, `$5%` and dates or times in parentheses stay text | | 2026-10-23 | Calc: `INDEX(A1:C3,2)` (one index on a reference of several rows and columns) is `#REF!` as in Excel, instead of the whole row; `INDEX(A1:C3,2,)` still gives the row | | 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) | +| 2026-10-22 | Typing in a cell and then choosing File › New no longer strands the first workbook: the entry is committed into its own workbook before any File or window command (a refused entry stays open and stops the command), View › Switch Windows and the macOS Window menu list the open workbooks with unsaved markers, `view.switchWindows {doc}` switches by a stable workbook id, and the macOS Quit item asks about unsaved workbooks | | 2026-10-21 | Page Setup paper sizes match Japanese Excel: `B4`/`B5`/`B6` are the JIS sizes (ISO B4/B5/B6 stay reachable by name), so a Japanese B5 worksheet prints at 182×257 mm; Japanese Postcard (100×148 mm), Double Postcard (148×200 mm), Envelope #3/#4 (120×235/90×205 mm), Kaku #2/#3 (240×332/216×277 mm) and A6 are added, both sizes reach the PDF page (`file.exportPdf`/`file.print` MediaBox) and the size survives an XLSX round-trip; the Page Layout Size menu lists them. `B4`/`B5` used to be the ISO sizes, which is changed here | | 2026-10-20 | Japanese interface catalog covers every key the Brazilian Portuguese catalog translates: the 398-label shared core plus 545 dialog, message, Backstage and menu strings (943 total, ~43% of ~2,200). A test parses `pt.rs`'s source, so the two catalogs stay in step | | 2026-10-20 | Interface translated into Dutch (518 labels: ribbon, context menus, File view, dialog titles, Options); `nl`, `nl-NL`, `nl-BE` locale tags resolve to Dutch | @@ -242,6 +243,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-23 | minor | Progress log: accounting negatives in number text | | 2026-10-23 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! | | 2026-10-22 | trivial | Progress log: formula hardening (#2163, #2162) | +| 2026-10-22 | minor | Progress log: drafts stay in their workbook, Switch Windows, macOS Quit asks to save | | 2026-10-21 | minor | Page Setup paper sizes: JIS B4/B5/B6 (ISO by name), Japanese postcards and envelopes, A6; PDF page size follows, XLSX round-trips | | 2026-10-20 | minor | Dutch (nl) listed under other shipped languages; progress log row | | 2026-10-19 | minor | Progress log: long flat operator chains (#916) | diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 14375944c..c43e00045 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -35,7 +35,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 076070cd5..4c41f05c3 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![ @@ -186,8 +195,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", @@ -213,7 +233,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(); @@ -248,7 +268,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(); @@ -257,12 +277,52 @@ 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 + && let Some(menu) = &self.window_menu + { + 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()); + } + } 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; + } + // AppKit flips a check item when it is chosen, so the active check is put back every poll. + for (item, w) in self.window_items.iter().zip(&self.windows) { + if item.is_checked() != w.active { + item.set_checked(w.active); + } + } + } + + 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() { @@ -322,6 +382,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 e0ae494fc..954583a99 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 @@ -835,12 +843,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(), )) } @@ -1036,7 +1058,7 @@ fn denied_script_step(id: &str, params: &Json) -> Option { } // Scripts are scoped to their workbook: a step that switches the active window would // point every later step at another open document, so those are refused too. - if id.starts_with("window.") { + if id.starts_with("window.") || matches!(id, "view.switchWindows" | "view.newWindow" | "view.hideWindow" | "view.unhideWindow") { return Some(format!("scripts can't run `{id}`: it reaches outside the workbook")); } if params.get("path").is_some() { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 37580305d..c1b9fa13b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -180,6 +180,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. @@ -493,6 +495,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 46cc1f5f4..6ca6db43a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4825,6 +4825,38 @@ 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(", /// Move the caret to the end on the next frame. pub caret_to_end: bool, + /// The cell's text when an F2-style edit began. Committing it unchanged leaves the cell alone + /// (no undo step, the workbook stays clean). `None` for an edit started by typing. + pub original: Option, } 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(); + let original = (!enter_mode).then(|| text.clone()); EditState { + doc, sheet, locale: Default::default(), cell, @@ -63,6 +70,7 @@ impl EditState { keep_selection: false, caret_to_end: true, completion: None, + original, } } @@ -384,7 +392,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"); @@ -394,13 +402,13 @@ 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 a_pasted_name_replaces_the_selected_text() { - let mut e = EditState::new(0, CellRef::new(0, 0), "=OldName+1".into(), false, false); + let mut e = EditState::new(1, 0, CellRef::new(0, 0), "=OldName+1".into(), false, false); e.sync_caret = false; (e.anchor, e.caret) = (8, 1); e.autocomplete = vec!["SUM".into()]; @@ -416,12 +424,12 @@ mod tests { #[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))); } @@ -457,7 +465,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 1c5704ee3..c50d4cb8c 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1828,6 +1828,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) { @@ -3214,7 +3215,7 @@ fn editor_width(text_w: f32, cell_w: f32, size: f32) -> f32 { /// Draws the in-cell editor, growing and wrapping with its text, mirroring for right-to-left sheets, choosing contrasting caret ink and clipping cells hidden under frozen panes. 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/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs index c35a3bbad..ef0011861 100644 --- a/crates/ui-egui/src/keytips/keytip_table/generated.rs +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -356,7 +356,7 @@ pub const SEQUENCES: &[(&str, Entry)] = &[ ("WVH", Entry::Finish(Action::Command("view.headings"))), // Excel: Toggle Headings; containment(Headings) ("WVN", Entry::Finish(Action::Pending("view.navigation"))), // pending; Excel: Navigation; Navigation pane; reserved until the command lands ("WVR", Entry::Finish(Action::Command("view.ruler"))), // Excel: Ruler - ("WW", Entry::Finish(Action::Command("view.switchWindows"))), // Excel: Switch Windows + ("WW", Entry::Finish(Action::Control("view.switchWindows"))), // Excel: Switch Windows ("JAM", Entry::Finish(Action::Command("chart.formatSelection"))), // Excel: Chart format: Format Selection pane; containment(Format Selection) ("JCA", Entry::Finish(Action::Command("chart.addElement"))), // Excel: Chart: Add Chart Element; containment(Add Chart Element) ("JCC", Entry::Finish(Action::Command("chart.changeType"))), // Excel: Chart: Change Chart Type; containment(Change Chart Type) diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json index 5be64f867..f0e2c8f99 100644 --- a/crates/ui-egui/src/keytips/keytips.json +++ b/crates/ui-egui/src/keytips/keytips.json @@ -2471,7 +2471,7 @@ }, { "seq": "WW", - "kind": "command", + "kind": "control", "target": "view.switchWindows", "excel": "Switch Windows", "note": "" diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6386530c2..ea1060f2c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -178,6 +178,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 { @@ -328,6 +345,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, /// The update check, the startup prompt and a running self-update (desktop only). #[cfg(not(target_arch = "wasm32"))] updates_rt: updates_ui::Runtime, @@ -384,6 +404,7 @@ impl SheetApp { tab_run: None, started: now_ms(), allow_close: false, + open_switch_windows: false, #[cfg(not(target_arch = "wasm32"))] updates_rt: Default::default(), } @@ -446,30 +467,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) { @@ -605,8 +626,19 @@ impl SheetApp { if id == "insert.recommendedCharts" && params.get("type").is_none() { return self.open_dialog("recommendedCharts", json!({})); } - if let Err(e) = self.run(id, params) { - self.alert(e); + // 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; + } + match self.run(id, params) { + // A refused cell draft already shows its own message (with the rule's title). + Err(e) if self.message.as_ref().is_some_and(|(_, m)| *m == e) => {} + Err(e) => self.alert(e), + Ok(_) => {} } } @@ -950,6 +982,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)) } @@ -1029,7 +1072,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); @@ -1039,13 +1082,54 @@ 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; a draft its own workbook refuses (validation, a bad + /// formula) brings that workbook back to the front with the draft still open. 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) { + // Refused: its workbook stays in front with the draft open and the reason shown. + return false; + } + self.session.set_active(back); + true + } + + /// Commits the cell and text-box drafts, as Enter would, before a command that can show + /// another workbook or write this one to a file, so a draft never lands in another workbook. + /// A draft its workbook refuses stays open with the reason shown, and the command doesn't run. + fn settle_drafts(&mut self) -> Result<(), String> { + if !self.commit_edit(0, 0, false, false) { + return Err(self.message.as_ref().map(|(_, m)| m.clone()).unwrap_or_else(|| "The cell being edited can't be entered.".into())); + } + self.commit_text_box_edit() + } + + 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 { Some(full) if full.to_lowercase().starts_with(&ed.text.to_lowercase()) => full.clone(), _ => ed.text.clone(), }; + // F2 then Enter (or a file command) on an untouched draft: nothing to write. + if !fill_selection && !array && ed.original.as_ref() == Some(&text) { + if dr != 0 || dc != 0 { + self.move_after_enter(dr, dc); + } + return true; + } let text = match self.session.active().map(|d| formula_locale::canonical(&text, ed.locale, &d.wb, ed.sheet, ed.cell)) { Some(Ok(text)) => text, Some(Err(error)) => { @@ -1501,6 +1585,12 @@ 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 that + // workbook refuses it, it comes back to the front with the draft open. + 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); #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 0305884ed..673cb6b3b 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2531,6 +2531,7 @@ fn view(app: &mut SheetApp, ui: &mut Ui) { ], ); }); + switch_windows(app, ui); ui.vertical(|ui| { let label_response = ui.label(lang.tr("Display theme")); let mode = app.ui.theme_mode(); @@ -2595,6 +2596,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::Role::Button, ui.is_enabled(), "Switch Windows")); + control_keytip(app, ui, &anchor, "view.switchWindows"); + let forced = std::mem::take(&mut app.open_switch_windows) | invoke(app, "view.switchWindows"); + egui::Popup::menu(&anchor).open_memory(popup_command(forced, anchor.clicked())).show(|ui| { + for w in app.open_windows() { + let label = theme::available_text(ui, &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/it/file_menu.rs b/crates/ui-egui/tests/it/file_menu.rs index 835fdf965..f473233ff 100644 --- a/crates/ui-egui/tests/it/file_menu.rs +++ b/crates/ui-egui/tests/it/file_menu.rs @@ -279,6 +279,113 @@ 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_stops_the_switch_and_stays_open() { + let mut h = harness(Services::default()); + let book1 = typing_in_book1_then_new(&mut h); + let book2 = h.state().session.doc().unwrap().uid; + 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); + h.state_mut().run_or_alert("view.switchWindows", serde_json::json!({"doc": book1})); + h.run_steps(2); + assert_eq!(h.state().session.doc().unwrap().uid, book2, "the switch doesn't happen"); + assert_eq!(h.state().editor.as_ref().map(|ed| ed.text.as_str()), Some("Purple"), "the draft stays open to fix"); + assert_eq!(a1(&h, 1), Value::Empty, "Book2 is unchanged"); + assert!(h.state().message.is_some(), "the refusal is reported"); + + // Esc drops the draft; then the switch goes through. + h.state_mut().message = None; + h.state_mut().cancel_edit(); + h.state_mut().run_or_alert("view.switchWindows", serde_json::json!({"doc": book1})); + h.run_steps(2); + assert_eq!(h.state().session.doc().unwrap().uid, book1); + assert!(h.state().message.is_none()); +} + +#[test] +fn an_untouched_f2_draft_leaves_the_workbook_clean_on_a_file_command() { + let dir = std::env::temp_dir().join(format!("gridcraft-f2-draft-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Book.xlsx").to_string_lossy().into_owned(); + let mut h = harness(Services::default()); + h.state_mut().run("cell.set", serde_json::json!({"cell": "A1", "input": "=1+2"})).unwrap(); + h.state_mut().run("file.save", serde_json::json!({"path": &path})).unwrap(); + h.run_steps(2); + let undo = h.state().session.doc().unwrap().undo.len(); + h.key_press(egui::Key::F2); + h.run_steps(2); + assert_eq!(h.state().editor.as_ref().map(|ed| ed.text.as_str()), Some("=1+2")); + h.state_mut().run_or_alert("file.new", serde_json::json!({})); + h.run_steps(2); + assert!(h.state().editor.is_none()); + let book1 = &h.state().session.documents()[0]; + assert!(!book1.is_dirty(), "nothing was typed, so nothing changed"); + assert_eq!(book1.undo.len(), undo, "and no undo step was added"); + let _ = std::fs::remove_dir_all(&dir); +} + 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()); diff --git a/crates/ui-egui/tests/it/keytips_generated.rs b/crates/ui-egui/tests/it/keytips_generated.rs index 60368239a..a22fabc98 100644 --- a/crates/ui-egui/tests/it/keytips_generated.rs +++ b/crates/ui-egui/tests/it/keytips_generated.rs @@ -2647,7 +2647,8 @@ fn keytip_wvr_() { fn keytip_ww_() { let mut h = harness(blank()); sequence(&mut h, "WW", false); - assert!(h.state().message.is_none() && h.state().toast.is_none(), "WW must run view.switchWindows cleanly"); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "WW must route to its control without an error box"); + assert_eq!(h.state().keytips.prefix(), None); } #[test] diff --git a/docs/ui-parity.md b/docs/ui-parity.md index ed875cc8e..e7c04235f 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -35,6 +35,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Dark mode, system appearance | yes | yes | done (10-09) | | Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab, whose Different First Page and Different Odd & Even Pages give the first page and the even pages their own header and footer (#344), and whose Scale with Document and Align with Page Margins set whether header and footer text follows the print scale and the side margins, on paper and in the view | margins drag on the rulers (#792, with header and footer margins); header/footer pictures missing (1–3 h). Unverified: with Align with Page Margins off, header and footer text sits 0.7 in from the paper edge, a value not yet measured in the target app | | Multiple windows per workbook | yes | no (#162) | see [hardware-parity.md](hardware-parity.md) | +| Switch Windows, unsaved workbooks on quit | yes | yes (10-22): View › Switch Windows and the macOS Window menu list the open workbooks (active one checked, unsaved ones marked •) and bring the chosen one forward (`view.switchWindows {doc}`); a cell being typed is entered into its own workbook before New, Open, Close, Save, Print and window switches, and a refused entry stays open and stops the command; quitting (window close, Cmd+Q, the macOS Quit item) asks Save / Don't Save / Cancel for each unsaved workbook | one OS window for all workbooks (#162) | ## Grid interaction @@ -201,6 +202,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | 2026-10-24 | minor | When the fill starts from a date, the right-drag fill menu and the Auto Fill Options button also offer Fill Days, Fill Weekdays (Saturday and Sunday skipped), Fill Months and Fill Years (`edit.autoFill` with `mode` set to `days`, `weekdays`, `months` or `years`). A lone date steps by one unit; two or more dates step by the difference between the first two in that unit, or one unit when they fall in the same one. Fill Months and Fill Years keep the day of the month, clamped to the month's last day. Every fill by months or years (these two, a drag of dates a month apart, and Series by Month or Year) keeps the time of day of a date with a time, which used to be dropped | | 2026-10-22 | minor | Show Outline Symbols (`view.showOutlineSymbols`, Ctrl+8): a sheet can hide the outline's level buttons and brackets while keeping its outline, as Excel saves with `showOutlineSymbols="0"` (read and written); the grid then gives the bands no room | | 2026-10-22 | minor | A number in a repeat-fill format (`*`, as in the Accounting format) that is too wide for its column shows #### like any other number, instead of the fill rebuilding the value so its leading digits were cut off (2,509.00 CHF drawn as "509.00 CHF") | +| 2026-10-22 | minor | New workbook no longer loses the first one: the cell being typed is entered into its own workbook before any File or window command (an entry the workbook refuses stays open and the command doesn't run; an untouched F2 draft changes nothing), the editor is tied to its workbook, View › Switch Windows and the macOS Window menu list the open workbooks, and the macOS Quit item asks about unsaved workbooks like the close button | | 2026-10-21 | minor | Background error checking, first part (#1994): a constant whose text would be a number if typed (signs, thousands, currency, percent, exponent, parenthesised negative) gets Excel's green corner triangle; the active cell then shows a warning button whose menu names the problem and offers Convert to Number for the selection (`formulas.convertToNumber`). Dates, times and fractions kept as text and formula results are not flagged, as in Excel | | 2026-10-21 | minor | Paste Special laid out as Excel's: Paste and Operation options each in two columns inside a frame, Skip blanks and Transpose side by side, Paste Link as a button that pastes references at once; labels translated (the generic form didn't translate them) and the window no longer runs past the screen | | 2026-10-21 | minor | Form dialogs (conditional-format quick rules, New Name, Remove Duplicates, Text to Columns and every other `Dialog::form`) fit their content: OK and Cancel sit in a row under the last field instead of a right-to-left layout that took the screen's whole height and left the buttons floating halfway down |