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