From 1794c389e58ce42e538a88468d7fe6a426dff387 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 05:01:47 +0000 Subject: [PATCH 1/7] 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. --- apps/gridcraft/src/main.rs | 2 +- apps/gridcraft/src/native_menu.rs | 68 ++++++++++++++-- crates/engine/src/cmd/extra.rs | 28 ++++++- crates/engine/src/lib.rs | 6 ++ crates/engine/src/tests.rs | 31 ++++++++ crates/ui-egui/src/editor.rs | 15 ++-- crates/ui-egui/src/grid.rs | 3 +- crates/ui-egui/src/lib.rs | 127 ++++++++++++++++++++++++------ crates/ui-egui/src/ribbon.rs | 19 +++++ crates/ui-egui/tests/file_menu.rs | 77 ++++++++++++++++++ 10 files changed, 337 insertions(+), 39 deletions(-) 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()); From a0d54b49d47c081b33dc18c44b65da74a3916413 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 05:12:23 +0000 Subject: [PATCH 2/7] A refused cell draft stops the command; an untouched F2 draft changes nothing - File and window commands stop when the workbook refuses the cell or text-box draft (validation Stop, bad formula, protected text box): the draft stays open in its cell with the reason shown, to fix or Esc. The per-frame backstop brings a refused draft's workbook back to the front. - An F2 draft committed with its text unchanged writes nothing: no undo step, and the workbook stays clean through Save, Print or New. - macOS Window menu: the active workbook's check is put back every poll, since choosing a check item flips it. --- apps/gridcraft/src/native_menu.rs | 39 ++++++++++++++++------------ crates/ui-egui/src/editor.rs | 5 ++++ crates/ui-egui/src/lib.rs | 43 ++++++++++++++++++------------- crates/ui-egui/tests/file_menu.rs | 38 ++++++++++++++++++++++++--- 4 files changed, 86 insertions(+), 39 deletions(-) diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs index 2c2866379..877c82fd8 100644 --- a/apps/gridcraft/src/native_menu.rs +++ b/apps/gridcraft/src/native_menu.rs @@ -283,27 +283,32 @@ impl NativeMenu { /// step with the session. fn sync_windows(&mut self, app: &SheetApp) { let windows = app.open_windows(); - if windows == self.windows { - return; + 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; } - 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()); + // 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); } - } 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) { diff --git a/crates/ui-egui/src/editor.rs b/crates/ui-egui/src/editor.rs index eeeaf12da..3f23e8bed 100644 --- a/crates/ui-egui/src/editor.rs +++ b/crates/ui-egui/src/editor.rs @@ -45,11 +45,15 @@ pub struct EditState { pub completion: Option, /// 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(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, @@ -68,6 +72,7 @@ impl EditState { sync_caret: true, caret_to_end: true, completion: None, + original, } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 001abaad5..cc478168e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -412,7 +412,7 @@ impl SheetApp { || 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(); + self.settle_drafts()?; } if matches!(id, "file.save" | "file.saveAs" | "file.saveBytes") { self.store_view_state(); @@ -575,8 +575,11 @@ impl SheetApp { self.open_switch_windows = true; return; } - if let Err(e) = self.run(id, params) { - self.alert(e); + 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(_) => {} } } @@ -1013,8 +1016,8 @@ 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. + /// 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); @@ -1028,25 +1031,21 @@ impl SheetApp { let back = self.session.active_index(); self.session.set_active(owner); if !self.commit_edit_here(dr, dc, array, fill_selection) { - self.cancel_edit(); + // Refused: its workbook stays in front with the draft open and the reason shown. + return false; } 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) { + /// 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) { - 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))); - } + 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 { @@ -1056,6 +1055,13 @@ impl SheetApp { 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)) => { @@ -1355,7 +1361,8 @@ 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. + // 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); } diff --git a/crates/ui-egui/tests/file_menu.rs b/crates/ui-egui/tests/file_menu.rs index 415e09902..5c447cb63 100644 --- a/crates/ui-egui/tests/file_menu.rs +++ b/crates/ui-egui/tests/file_menu.rs @@ -351,19 +351,49 @@ fn new_workbook_keeps_the_typed_cell_in_the_first_and_switch_windows_brings_it_b } #[test] -fn a_draft_its_workbook_refuses_is_dropped_when_switching_away() { +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); - 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!(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>) { From 4bafd4e8ee5f38b068720156b9057eed5ed82a80 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 05:12:23 +0000 Subject: [PATCH 3/7] Docs: Switch Windows and drafts kept in their workbook (UI parity, progress log) --- ROADMAP.md | 4 +++- docs/ui-parity.md | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2e04e9123..9617ef1e6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (Page Setup paper sizes: JIS B4/B5/B6, the Japanese postcards and envelopes, and A6; long flat operator chains up to Excel's formula limit) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-22 · **Change:** minor (no lost workbook after New: drafts stay in their workbook, Switch Windows, macOS Quit asks to save) · **Target:** Microsoft Excel (Microsoft 365) GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web. @@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 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 | @@ -220,6 +221,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 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/docs/ui-parity.md b/docs/ui-parity.md index 07797d70d..b70682ac3 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Switch Windows lists the open workbooks; a cell being typed stays in its own workbook through New, Open and window switches; macOS Quit asks about unsaved workbooks) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -34,6 +34,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 | margins drag on the rulers (#792, with header and footer margins); header/footer pictures, Different First Page / Odd & Even missing (1–3 h) | | 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 @@ -176,6 +177,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 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 | From 22aec7e667216de3f29e3e938993edb101555381 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 02:24:45 +0000 Subject: [PATCH 4/7] G1: build on current main Scripts can't run the View tab's window commands (Switch Windows, New Window, Hide, Unhide): they change the active workbook, as window.* steps do. The Switch Windows button names its accessibility role with egui::Role (egui 0.37). --- crates/engine/src/cmd/extra.rs | 2 +- crates/engine/src/tests.rs | 3 +++ crates/ui-egui/src/ribbon.rs | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 891273b43..954583a99 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -1058,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/tests.rs b/crates/engine/src/tests.rs index 6648b9b7e..6ca6db43a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4907,6 +4907,9 @@ fn a_script_cannot_switch_windows() { s.execute("automate.trustScripts", json!({"trusted": true})).unwrap(); let err = s.execute("automate.runScript", json!({"name": "t"})).expect_err("script switching windows"); assert!(err.to_string().contains("window.activate"), "{err}"); + s.execute("automate.newScript", json!({"name": "sw", "commands": [{"command": "view.switchWindows", "params": {"doc": 1}}]})).unwrap(); + let err = s.execute("automate.runScript", json!({"name": "sw"})).expect_err("script switching windows from the View tab"); + assert!(err.to_string().contains("view.switchWindows"), "{err}"); // A benign script runs. s.execute("automate.newScript", json!({"name": "ok", "commands": [{"command": "cell.set", "params": {"cell": "A1", "input": "=1+1"}}]})).unwrap(); s.execute("automate.runScript", json!({"name": "ok"})).unwrap(); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index af7114c19..a3c3b69f3 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2600,7 +2600,7 @@ fn view(app: &mut SheetApp, ui: &mut Ui) { /// 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")); + 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); egui::Popup::menu(&anchor).open_memory(popup_command(forced, anchor.clicked())).show(|ui| { From 5900247075248464cb1a9439bc0408353de9b162 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 02:56:59 +0000 Subject: [PATCH 5/7] =?UTF-8?q?G1:=20Switch=20Windows=20draws=20its=20chec?= =?UTF-8?q?k=20with=20the=20chrome=20fallback=20when=20no=20face=20has=20?= =?UTF-8?q?=E2=9C=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/src/ribbon.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index a3c3b69f3..cb1573c1f 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2605,7 +2605,7 @@ fn switch_windows(app: &mut SheetApp, ui: &mut Ui) { 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()); + 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(); From 320bcfcf54565d6be797f87a3762bff0ac96030c Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 03:16:42 +0000 Subject: [PATCH 6/7] G1: regenerate the key tip tests --- crates/ui-egui/tests/it/keytips_generated.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/it/keytips_generated.rs b/crates/ui-egui/tests/it/keytips_generated.rs index 60368239a..498c1a5d8 100644 --- a/crates/ui-egui/tests/it/keytips_generated.rs +++ b/crates/ui-egui/tests/it/keytips_generated.rs @@ -2647,7 +2647,7 @@ 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().session.journal.iter().any(|(id, _)| id == "view.switchWindows"), "WW must journal view.switchWindows"); } #[test] From 380be36ac30bafb0d76fb3350e6c863d26f3320b Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 03:33:34 +0000 Subject: [PATCH 7/7] G1: the W, W key tip opens Switch Windows through its control --- crates/ui-egui/src/keytips/keytip_table/generated.rs | 2 +- crates/ui-egui/src/keytips/keytips.json | 2 +- crates/ui-egui/src/ribbon.rs | 2 +- crates/ui-egui/tests/it/keytips_generated.rs | 3 ++- 4 files changed, 5 insertions(+), 4 deletions(-) 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/ribbon.rs b/crates/ui-egui/src/ribbon.rs index cb1573c1f..673cb6b3b 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2602,7 +2602,7 @@ 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); + 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())); diff --git a/crates/ui-egui/tests/it/keytips_generated.rs b/crates/ui-egui/tests/it/keytips_generated.rs index 498c1a5d8..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().session.journal.iter().any(|(id, _)| id == "view.switchWindows"), "WW must journal view.switchWindows"); + 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]