diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index cd8311b345..ddc6921d90 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -42,6 +42,8 @@ muda = { version = "0.21", default-features = false } # by PdfCraft and PhotoCraft) registers the handler (Objective-C interop needs unsafe, which our # workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping. fmv-macos-events = "=0.1.0" +objc2 = "=0.6.5" +objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSWindow"] } # tests/mac_menu_appkit.rs: reads AppKit's menu bar back. Already in Cargo.lock (muda, winit); # pinned exactly to the same versions. diff --git a/apps/gridcraft/src/apple_events.rs b/apps/gridcraft/src/apple_events.rs index 405ee89a04..c6b4c78c14 100644 --- a/apps/gridcraft/src/apple_events.rs +++ b/apps/gridcraft/src/apple_events.rs @@ -10,7 +10,6 @@ //! that leaves winit's delegate alone, behind a safe main-thread API. use fmv_macos_events::{Event, Inbox, Registration}; -use gridcraft_ui_egui::SheetApp; /// Keeps the Apple-event handlers registered; hold it until the event loop returns. pub struct AppleEvents { @@ -34,16 +33,8 @@ impl AppleEvents { } } -/// Open the documents and act on the quit requests that arrived since the last frame. -pub fn poll(inbox: &Inbox, app: &mut SheetApp, ctx: &egui::Context) { - for e in inbox.drain() { - match e { - Event::Open(paths) => { - for p in paths.iter().filter_map(|p| p.to_str()) { - app.open_path(p); - } - } - Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close), - } - } +/// Events received since the last frame. The desktop shell routes open requests because it owns +/// the workbook windows and can focus an existing one or replace an untouched blank workbook. +pub fn drain(inbox: &Inbox) -> Vec { + inbox.drain() } diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 7c0eb04e6c..7b5f4d9f28 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -21,45 +21,430 @@ mod print; use std::cell::RefCell; use std::rc::Rc; -use gridcraft_engine::Session; -use gridcraft_ui_egui::{Services, SheetApp}; +use gridcraft_engine::{Prefs, Session}; +use gridcraft_ui_egui::{Services, SheetApp, UiState, WorkbookWindowAction, WorkbookWindowInfo}; use serde_json::json; -struct App(SheetApp, #[cfg(target_os = "macos")] Option, #[cfg(target_os = "macos")] fmv_macos_events::Inbox); +#[derive(Clone)] +struct AppPreferences { + ui: UiState, + engine: Prefs, +} + +impl AppPreferences { + fn new(app: &SheetApp) -> Self { + Self { ui: app.ui.clone(), engine: app.session.prefs.clone() } + } + + fn update_from(&mut self, app: &SheetApp) { + self.ui.dark = app.ui.dark; + self.ui.system_theme = app.ui.system_theme; + self.ui.status_bar = app.ui.status_bar; + self.ui.animations = app.ui.animations; + self.ui.recent.clone_from(&app.ui.recent); + self.ui.language = app.ui.language; + self.ui.interface_scale = app.ui.interface_scale; + self.engine = app.session.prefs.clone(); + } + + fn apply_to(&self, app: &mut SheetApp) { + app.ui.dark = self.ui.dark; + app.ui.system_theme = self.ui.system_theme; + app.ui.status_bar = self.ui.status_bar; + app.ui.animations = self.ui.animations; + app.ui.recent.clone_from(&self.ui.recent); + app.ui.language = self.ui.language; + app.ui.interface_scale = self.ui.interface_scale; + app.session.prefs = self.engine.clone(); + } +} + +struct App { + windows: Vec, + preferences: AppPreferences, + focused: Option, + focus_request: Option, + #[cfg(target_os = "macos")] + root_hidden: bool, + #[cfg(target_os = "macos")] + native_menu: Option, + #[cfg(target_os = "macos")] + apple_events: Option, +} + +impl App { + fn new(app: SheetApp) -> Self { + let focused = app.session.active().map(|d| d.uid); + let preferences = AppPreferences::new(&app); + let mut this = Self { + windows: vec![app], + preferences, + focused, + focus_request: None, + #[cfg(target_os = "macos")] + root_hidden: false, + #[cfg(target_os = "macos")] + native_menu: None, + #[cfg(target_os = "macos")] + apple_events: None, + }; + this.split_workbooks(); + this + } + + fn apply_preferences(&mut self) { + for app in &mut self.windows { + self.preferences.apply_to(app); + } + } + + fn capture_preferences(&mut self) { + let index = + self.focus_request.or(self.focused).and_then(|id| self.windows.iter().position(|app| Self::window_id(app) == Some(id))).unwrap_or(0); + if let Some(app) = self.windows.get(index) { + self.preferences.update_from(app); + } + self.apply_preferences(); + } + + fn viewport_id(id: u64) -> egui::ViewportId { + egui::ViewportId::from_hash_of(("workbook", id)) + } + + fn viewport_for_window(&self, id: u64) -> egui::ViewportId { + if self.windows.first().is_some_and(|app| Self::window_id(app) == Some(id)) { egui::ViewportId::ROOT } else { Self::viewport_id(id) } + } + + fn window_id(app: &SheetApp) -> Option { + app.session.active().map(|d| d.uid) + } + + fn window_builder(app: &SheetApp, active: bool) -> egui::ViewportBuilder { + let title = app.session.active().map(|d| d.display_title()).unwrap_or_else(|| "GridCraft".into()); + let mut viewport = egui::ViewportBuilder::default() + .with_title(title) + .with_inner_size([1440.0, 900.0]) + .with_min_inner_size([640.0, 420.0]) + .with_app_id("ai.storyteller.gridcraft") + .with_active(active) + .with_drag_and_drop(true); + if cfg!(target_os = "macos") { + viewport = viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false); + } + if let Some(icon) = icon() { + viewport = viewport.with_icon(icon); + } + viewport + } + + fn summaries(&self, active: u64) -> Vec { + self.windows + .iter() + .filter_map(|app| { + let document = app.session.active()?; + Some(WorkbookWindowInfo { + id: document.uid, + title: document.display_title(), + dirty: document.is_dirty(), + active: document.uid == active, + }) + }) + .collect() + } + + fn same_path(left: &str, right: &str) -> bool { + let left = std::path::Path::new(left); + let right = std::path::Path::new(right); + left == right || left.canonicalize().ok().zip(right.canonicalize().ok()).is_some_and(|(left, right)| left == right) + } + + fn open_path(&mut self, path: &str) { + if let Some(id) = self.windows.iter().find_map(|app| { + let document = app.session.active()?; + document.path.as_deref().is_some_and(|open| Self::same_path(open, path)).then_some(document.uid) + }) { + self.focus_request = Some(id); + return; + } + + let target = self.focused.and_then(|id| self.windows.iter().position(|app| Self::window_id(app) == Some(id))).unwrap_or(0); + let Some(app) = self.windows.get_mut(target) else { return }; + let reuse_window = app.session.documents().is_empty() + || (app.session.documents().len() == 1 && app.session.active().is_some_and(|document| document.path.is_none() && !document.is_dirty())); + if !app.open_path(path) { + return; + } + self.preferences.update_from(app); + if reuse_window { + // The engine opens into an empty coordinator or replaces an untouched blank document + // in place. Keep that workbook in the same native window and bring it forward. + self.focus_request = Self::window_id(app); + } + self.split_workbooks(); + } + + fn split_workbooks(&mut self) { + let mut extracted = Vec::new(); + for app in &mut self.windows { + while app.session.documents().len() > 1 { + let Some(document) = app.session.take_document(1) else { break }; + extracted.push((app.ui.clone(), app.session.prefs.clone(), document)); + } + } + for (ui_state, prefs, mut document) in extracted { + if let Some(path) = document.path.as_deref() + && let Some(existing) = self.windows.iter().find_map(|app| { + let open = app.session.active()?; + (open.path.as_deref() == Some(path)).then_some(open.uid) + }) + { + self.focus_request = Some(existing); + continue; + } + if document.path.is_none() + && document.title.strip_prefix("Book").is_some_and(|suffix| suffix.parse::().is_ok()) + && self.windows.iter().any(|app| app.session.active().is_some_and(|open| open.path.is_none() && open.title == document.title)) + { + let number = (1..) + .find(|number| { + let title = format!("Book{number}"); + !self.windows.iter().any(|app| app.session.active().is_some_and(|open| open.path.is_none() && open.title == title)) + }) + .unwrap_or(1); + document.title = format!("Book{number}"); + } + let id = document.uid; + let mut session = Session::new(); + session.prefs = prefs; + session.add_document(document); + let mut app = SheetApp::new(session, services()); + app.ui = ui_state; + self.preferences.apply_to(&mut app); + self.windows.push(app); + self.focus_request = Some(id); + } + } + + fn handle_action(&mut self, action: WorkbookWindowAction) { + match action { + WorkbookWindowAction::Focus(id) => self.focus_request = Some(id), + WorkbookWindowAction::Close(id) => { + self.focus_request = Some(id); + if let Some(app) = self.windows.iter_mut().find(|app| Self::window_id(app) == Some(id)) { + app.close_document(app.session.active_index(), false); + } + } + } + } + + fn remove_closed(&mut self, ctx: &egui::Context) { + let control_rx = self.windows.iter_mut().find(|app| app.session.documents().is_empty()).and_then(|app| app.control_rx.take()); + if self.windows.first().is_some_and(|app| app.session.documents().is_empty()) { + if self.windows.len() == 1 { + #[cfg(not(target_os = "macos"))] + self.windows.clear(); + } else { + let promoted_id = Self::window_id(&self.windows[1]); + let geometry = promoted_id.map(|id| { + ctx.input_for(Self::viewport_id(id), |input| { + ( + input.viewport().outer_rect.map(|rect| rect.min), + input.viewport().inner_rect.map(|rect| rect.size()), + input.viewport().maximized, + input.viewport().fullscreen, + ) + }) + }); + self.windows.remove(0); + if let Some((position, size, maximized, fullscreen)) = geometry { + if let Some(position) = position { + ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(position)); + } + if let Some(size) = size { + ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size)); + } + if let Some(maximized) = maximized { + ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(maximized)); + } + if let Some(fullscreen) = fullscreen { + ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(fullscreen)); + } + } + self.focus_request = promoted_id; + } + } + let mut index = 1; + while index < self.windows.len() { + if self.windows[index].session.documents().is_empty() { + self.windows.remove(index); + } else { + index += 1; + } + } + if self.focused.is_some_and(|id| !self.windows.iter().any(|app| Self::window_id(app) == Some(id))) { + self.focused = self.windows.first().and_then(Self::window_id); + } + if let Some(receiver) = control_rx { + let target = self.focused.and_then(|id| self.windows.iter().position(|app| Self::window_id(app) == Some(id))).unwrap_or(0); + if let Some(app) = self.windows.get_mut(target) { + app.control_rx = Some(receiver); + } + } + } + + fn move_control_channel_to_focused(&mut self) { + let Some(focused) = self.focused else { return }; + let Some(target) = self.windows.iter().position(|app| Self::window_id(app) == Some(focused)) else { return }; + let Some(source) = self.windows.iter().position(|app| app.control_rx.is_some()) else { return }; + if source != target + && let Some(receiver) = self.windows[source].control_rx.take() + { + self.windows[target].control_rx = Some(receiver); + } + } +} impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.apply_preferences(); #[cfg(target_os = "macos")] { - if self.1.is_none() && std::env::var_os("GRIDCRAFT_NO_NATIVE_MENU").is_none() { - self.1 = Some(native_menu::NativeMenu::install(ctx)); + if self.native_menu.is_none() && std::env::var_os("GRIDCRAFT_NO_NATIVE_MENU").is_none() { + self.native_menu = Some(native_menu::NativeMenu::install(ctx)); + } + if let Some(menu) = &self.native_menu { + let target = self.focused.and_then(|id| self.windows.iter().position(|app| Self::window_id(app) == Some(id))).unwrap_or(0); + if let Some(app) = self.windows.get_mut(target) { + menu.poll(app, ctx); + } } - if let Some(m) = &self.1 { - m.poll(&mut self.0, ctx); + let events = self.apple_events.as_ref().map(apple_events::drain).unwrap_or_default(); + for event in events { + match event { + fmv_macos_events::Event::Open(paths) => { + for path in paths.iter().filter_map(|path| path.to_str()) { + self.open_path(path); + } + } + // #496 will provide the shared dirty-workbook quit flow. Preserve the + // existing close request until that PR is available to build on. + fmv_macos_events::Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close), + } } - apple_events::poll(&self.2, &mut self.0, ctx); } - self.0.logic(ctx); - self.0.poll_printing(ctx); - if ctx.input(|input| input.viewport().close_requested()) && self.0.printing.busy() { + self.capture_preferences(); + if let Some(app) = self.windows.first_mut() { + app.logic_for_workbook_window(ctx); + app.poll_printing(ctx); + } + if ctx.input(|i| i.viewport().close_requested()) { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + if let Some(app) = self.windows.first_mut() + && !app.printing.busy() + { + app.close_document(app.session.active_index(), false); + } } + self.move_control_channel_to_focused(); } - fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { - self.0.raw_input_hook(raw); + fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { + let viewport_id = ctx.viewport_id(); + let root_id = self.windows.first().and_then(Self::window_id); + let Some(index) = self.windows.iter().position(|app| { + Self::window_id(app).is_some_and(|id| { + let expected = if root_id == Some(id) { egui::ViewportId::ROOT } else { Self::viewport_id(id) }; + expected == viewport_id + }) + }) else { + return; + }; + self.windows[index].raw_input_hook(raw); // Files dropped on the window open as workbooks. - for f in std::mem::take(&mut raw.dropped_files) { - if let Some(p) = f.path().to_str().map(str::to_string).filter(|p| !p.is_empty()) { - self.0.open_path(&p); - } + let paths: Vec = std::mem::take(&mut raw.dropped_files) + .into_iter() + .filter_map(|file| file.path().to_str().map(str::to_string).filter(|path| !path.is_empty())) + .collect(); + self.focused = Self::window_id(&self.windows[index]); + for path in paths { + self.open_path(&path); } } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - self.0.ui(ui); + self.apply_preferences(); + self.split_workbooks(); + let activate = self.focus_request.take(); + let ctx = ui.ctx().clone(); + let mut actions = Vec::new(); + let mut focused = self.focused; + if let Some(root_id) = self.windows.first().and_then(Self::window_id) { + let summaries = self.summaries(root_id); + if ui.input(|i| i.viewport().focused.unwrap_or(false)) { + focused = Some(root_id); + } + if let Some(action) = self.windows[0].ui_with_workbook_windows(ui, Some(&summaries)) { + actions.push(action); + } + } + for index in 1..self.windows.len() { + let Some(id) = Self::window_id(&self.windows[index]) else { continue }; + let summaries = self.summaries(id); + let builder = Self::window_builder(&self.windows[index], activate == Some(id)); + let app = &mut self.windows[index]; + ctx.show_viewport_immediate(Self::viewport_id(id), builder, |ui, _class| { + app.logic_for_workbook_window(ui.ctx()); + app.poll_printing(ui.ctx()); + if ui.input(|i| i.viewport().focused.unwrap_or(false)) { + focused = Some(id); + } + if ui.input(|i| i.viewport().close_requested()) { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::CancelClose); + if !app.printing.busy() { + actions.push(WorkbookWindowAction::Close(id)); + } + } + if let Some(action) = app.ui_with_workbook_windows(ui, Some(&summaries)) { + actions.push(action); + } + }); + } + self.focused = focused; + self.capture_preferences(); + for action in actions { + self.handle_action(action); + } + self.split_workbooks(); + self.remove_closed(&ctx); + #[cfg(target_os = "macos")] + if let Some(root_id) = self.windows.first().and_then(Self::window_id) + && self.root_hidden + { + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true)); + self.root_hidden = false; + self.focus_request = Some(root_id); + } + if let Some(id) = self.focus_request.or(activate) + && self.windows.iter().any(|app| Self::window_id(app) == Some(id)) + { + ctx.send_viewport_cmd_to(self.viewport_for_window(id), egui::ViewportCommand::Focus); + self.focused = Some(id); + if self.focus_request.is_some() { + ctx.request_repaint(); + } + } + if self.windows.first().is_none_or(|app| app.session.documents().is_empty()) { + self.focused = None; + #[cfg(target_os = "macos")] + if !self.root_hidden { + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false)); + self.root_hidden = true; + } + #[cfg(not(target_os = "macos"))] + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } } fn on_exit(&mut self) { print::finish_pending_jobs(); - save_prefs(&self.0); + save_prefs(&self.preferences.ui, &self.preferences.engine); // macOS (#117): end the process here, before eframe tears the window down. AccessKit's // macOS adapter subclasses the window's content view and restores its class on teardown; // AppKit's Touch Bar support observes that view (KVO), so in the next display cycle @@ -121,16 +506,16 @@ fn load_prefs(app: &mut SheetApp) { } } -fn save_prefs(app: &SheetApp) { +fn save_prefs(ui: &UiState, prefs: &Prefs) { if !prefs_enabled() { return; } let Some(dir) = config_dir() else { return }; let _ = std::fs::create_dir_all(&dir); - if let Ok(b) = serde_json::to_vec_pretty(&app.ui) { + if let Ok(b) = serde_json::to_vec_pretty(ui) { let _ = std::fs::write(dir.join("ui.json"), b); } - if let Ok(b) = serde_json::to_vec_pretty(&app.session.prefs) { + if let Ok(b) = serde_json::to_vec_pretty(prefs) { let _ = std::fs::write(dir.join("prefs.json"), b); } } @@ -221,12 +606,16 @@ fn open_with_system(target: &str) { } fn icon() -> Option { - #[cfg(target_os = "macos")] - let bytes = include_bytes!("../../../assets/app-icon/gridcraft-macos-512.png"); - #[cfg(not(target_os = "macos"))] - let bytes = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.gridcraft.png"); - let img = image::load_from_memory(bytes).ok()?.to_rgba8(); - Some(egui::IconData { width: img.width(), height: img.height(), rgba: img.into_raw() }) + static ICON: std::sync::OnceLock> = std::sync::OnceLock::new(); + ICON.get_or_init(|| { + #[cfg(target_os = "macos")] + let bytes = include_bytes!("../../../assets/app-icon/gridcraft-macos-512.png"); + #[cfg(not(target_os = "macos"))] + let bytes = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.gridcraft.png"); + let img = image::load_from_memory(bytes).ok()?.to_rgba8(); + Some(egui::IconData { width: img.width(), height: img.height(), rgba: img.into_raw() }) + }) + .clone() } fn main() -> eframe::Result<()> { @@ -317,8 +706,8 @@ fn main() -> eframe::Result<()> { if cfg!(target_os = "macos") { viewport = viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false); } - if let Some(i) = icon() { - viewport = viewport.with_icon(i); + if let Some(icon) = icon() { + viewport = viewport.with_icon(icon); } let options = eframe::NativeOptions { viewport, ..Default::default() }; // Before eframe creates the wgpu instance: Windows starts on DirectX 12, or OpenGL when @@ -357,13 +746,14 @@ fn main() -> eframe::Result<()> { Err(e) => log::error!("control channel on port {port}: {e}"), } } - Ok(Box::new(App( - app, - #[cfg(target_os = "macos")] - None, - #[cfg(target_os = "macos")] - apple_events.connect(&cc.egui_ctx), - ))) + let shell = App::new(app); + #[cfg(target_os = "macos")] + let shell = { + let mut shell = shell; + shell.apple_events = Some(apple_events.connect(&cc.egui_ctx)); + shell + }; + Ok(Box::new(shell)) }), ) }; @@ -412,3 +802,163 @@ mod packaging_tests { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn csv(name: &str) -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!("gridcraft-window-{name}-{}.csv", std::process::id())); + std::fs::write(&path, "value\n1\n").unwrap(); + path + } + + #[test] + fn each_open_document_gets_its_own_window() { + let mut session = Session::new(); + session.new_workbook(); + session.new_workbook(); + let ids: Vec = session.documents().iter().map(|document| document.uid).collect(); + + let app = App::new(SheetApp::new(session, Services::default())); + + assert_eq!(app.windows.len(), 2); + assert!(app.windows.iter().all(|window| window.session.documents().len() == 1)); + assert_eq!(app.windows.iter().filter_map(App::window_id).collect::>(), ids); + } + + #[test] + fn blank_workbook_titles_are_unique_across_windows() { + let mut session = Session::new(); + session.new_workbook(); + if let Some(document) = session.active_mut() { + document.title = "Report".into(); + } + session.new_workbook(); + let mut app = App::new(SheetApp::new(session, Services::default())); + + app.windows[0].session.new_workbook(); + app.split_workbooks(); + + let titles: Vec = app.windows.iter().filter_map(|window| window.session.active().map(|document| document.title.clone())).collect(); + assert_eq!(titles, ["Report", "Book1", "Book2"]); + } + + #[test] + fn opening_a_file_reuses_an_untouched_blank_window() { + let path = csv("replace-blank"); + let mut session = Session::new(); + session.new_workbook(); + let mut app = App::new(SheetApp::new(session, Services::default())); + + app.open_path(path.to_str().unwrap()); + + assert_eq!(app.windows.len(), 1); + assert_eq!(app.windows[0].session.documents().len(), 1); + assert_eq!(app.windows[0].session.active().and_then(|document| document.path.as_deref()), path.to_str()); + let _ = std::fs::remove_file(path); + } + + #[test] + fn opening_a_file_without_a_focused_workbook_uses_the_coordinator_window() { + let path = csv("empty-coordinator"); + let mut app = App::new(SheetApp::new(Session::new(), Services::default())); + assert_eq!(app.focused, None); + + app.open_path(path.to_str().unwrap()); + + assert_eq!(app.windows.len(), 1); + assert_eq!(app.windows[0].session.active().and_then(|document| document.path.as_deref()), path.to_str()); + assert!(app.focus_request.is_some()); + let _ = std::fs::remove_file(path); + } + + #[test] + fn opening_a_file_beside_a_changed_blank_uses_a_new_window() { + let path = csv("keep-dirty-blank"); + let mut session = Session::new(); + session.new_workbook(); + session.execute("cell.set", json!({"cell": "A1", "input": "keep me"})).unwrap(); + let mut app = App::new(SheetApp::new(session, Services::default())); + + app.open_path(path.to_str().unwrap()); + + assert_eq!(app.windows.len(), 2); + assert!(app.windows.iter().any(|window| window.session.active().is_some_and(|document| document.path.is_none() && document.is_dirty()))); + assert!(app.windows.iter().any(|window| window.session.active().and_then(|document| document.path.as_deref()) == path.to_str())); + let _ = std::fs::remove_file(path); + } + + #[test] + fn reopening_a_file_focuses_its_existing_window() { + let first = csv("existing-first"); + let second = csv("existing-second"); + let mut session = Session::new(); + session.new_workbook(); + let mut app = App::new(SheetApp::new(session, Services::default())); + app.open_path(first.to_str().unwrap()); + let first_id = App::window_id(&app.windows[0]).unwrap(); + app.open_path(second.to_str().unwrap()); + assert_eq!(app.windows.len(), 2); + + app.focus_request = None; + app.focused = App::window_id(&app.windows[1]); + app.open_path(first.to_str().unwrap()); + + assert_eq!(app.windows.len(), 2); + assert_eq!(app.focus_request, Some(first_id)); + let _ = std::fs::remove_file(first); + let _ = std::fs::remove_file(second); + } + + #[test] + fn preferences_from_the_focused_window_are_shared_and_saved() { + let mut session = Session::new(); + session.new_workbook(); + session.new_workbook(); + let mut app = App::new(SheetApp::new(session, Services::default())); + let second = App::window_id(&app.windows[1]); + app.focused = second; + app.focus_request = None; + app.windows[1].ui.dark = true; + app.windows[1].ui.system_theme = false; + app.windows[1].ui.language = gridcraft_ui_egui::i18n::Language::Ja; + app.windows[1].ui.recent = vec!["second.xlsx".into()]; + app.windows[1].session.prefs.formula_bar = false; + + app.capture_preferences(); + + assert!(app.preferences.ui.dark); + assert_eq!(app.preferences.ui.language, gridcraft_ui_egui::i18n::Language::Ja); + assert_eq!(app.preferences.ui.recent, ["second.xlsx"]); + assert!(!app.preferences.engine.formula_bar); + assert!(app.windows.iter().all(|window| window.ui.dark)); + assert!(app.windows.iter().all(|window| window.ui.language == gridcraft_ui_egui::i18n::Language::Ja)); + assert!(app.windows.iter().all(|window| !window.session.prefs.formula_bar)); + } + + #[test] + fn egui_widget_ids_are_scoped_to_the_workbook_window() { + let mut session = Session::new(); + session.new_workbook(); + session.new_workbook(); + let app = App::new(SheetApp::new(session, Services::default())); + + assert_ne!(app.windows[0].widget_id("formula_bar"), app.windows[1].widget_id("formula_bar")); + } + + #[cfg(target_os = "macos")] + #[test] + fn closing_the_last_workbook_keeps_an_empty_app_coordinator() { + let mut session = Session::new(); + session.new_workbook(); + let mut app = App::new(SheetApp::new(session, Services::default())); + + app.windows[0].close_document(0, false); + app.remove_closed(&egui::Context::default()); + + assert_eq!(app.windows.len(), 1); + assert!(app.windows[0].session.documents().is_empty()); + assert_eq!(app.focused, None); + } +} diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs index 6985af3c0f..3310d7b2a6 100644 --- a/apps/gridcraft/src/native_menu.rs +++ b/apps/gridcraft/src/native_menu.rs @@ -7,10 +7,28 @@ use std::sync::mpsc::{Receiver, channel}; use gridcraft_ui_egui::SheetApp; use muda::accelerator::Accelerator; use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; +use objc2::MainThreadMarker; +use objc2_app_kit::NSApplication; use serde_json::{Value, json}; type Entry = (&'static str, &'static str, Option<&'static str>); +fn wakes_hidden_root(id: &str) -> bool { + matches!(id, "file.new" | "file.open" | "dialog:start") +} + +fn show_hidden_root() { + let Some(mtm) = MainThreadMarker::new() else { return }; + let app = NSApplication::sharedApplication(mtm); + let windows = app.windows(); + if windows.iter().any(|window| window.isVisible()) { + return; + } + if let Some(window) = windows.firstObject() { + window.makeKeyAndOrderFront(None); + } +} + /// (label, command or `dialog:name`, accelerator). `-` is a separator. fn tree() -> Vec<(&'static str, Vec)> { vec![ @@ -253,6 +271,9 @@ impl NativeMenu { let (tx, rx) = channel(); let ctx = ctx.clone(); MenuEvent::set_event_handler(Some(move |e: MenuEvent| { + if wakes_hidden_root(e.id.as_ref()) { + show_hidden_root(); + } let _ = tx.send(e); ctx.request_repaint(); })); @@ -311,6 +332,8 @@ impl NativeMenu { app.copy_to_clipboard(ctx, &id); } else if id == "edit.paste" { app.paste_from_clipboard(); + } else if id == "file.close" { + app.close_document(app.session.active_index(), false); } else { app.run_or_alert(&id, json!({})); } @@ -348,4 +371,12 @@ mod tests { assert!(!select_theme(&mut app, "view.formulaBar")); assert_eq!(app.ui.theme_mode(), "light"); } + + #[test] + fn workbook_creation_commands_wake_the_hidden_root() { + assert!(wakes_hidden_root("file.new")); + assert!(wakes_hidden_root("file.open")); + assert!(wakes_hidden_root("dialog:start")); + assert!(!wakes_hidden_root("file.save")); + } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 258a1cb729..8bbc124622 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -298,6 +298,22 @@ impl Session { } } } + /// Removes a document from this session and returns its complete state. + /// + /// Desktop shells use this to move a newly opened workbook into its own native window + /// without reloading it or losing undo history. + pub fn take_document(&mut self, i: usize) -> Option { + if i >= self.docs.len() { + return None; + } + let document = self.docs.remove(i); + if self.active > i { + self.active -= 1; + } else if self.active >= self.docs.len() { + self.active = self.docs.len().saturating_sub(1); + } + Some(document) + } /// A new blank workbook titled `BookN`. pub fn new_workbook(&mut self) -> usize { let n = (1..).find(|n| !self.docs.iter().any(|d| d.title == format!("Book{n}") && d.path.is_none())).unwrap_or(1); diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 922baabc59..beb6cdf8a9 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -156,7 +156,7 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { let mut out = Outcome::default(); let (nav_bg, nav_text, nav_sel) = if t.dark { (t.accent_soft, t.text, Color32::from_white_alpha(30)) } else { (t.accent, Color32::WHITE, Color32::from_black_alpha(46)) }; - egui::Panel::left("backstage_nav") + egui::Panel::left(app.widget_id("backstage_nav")) .exact_size(NAV_W) .resizable(false) .frame(egui::Frame::NONE.fill(nav_bg).inner_margin(egui::Margin { left: 0, right: 0, top: 10, bottom: 10 })) diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 5ab099c2d1..96fba89238 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -212,7 +212,7 @@ impl Default for View { /// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table. pub fn contributors_ui(ui: &mut egui::Ui) { - let id = egui::Id::new("credits_view"); + let id = ui.id().with("credits_view"); let mut v = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); ui.horizontal_wrapped(|ui| { ui.label("Show"); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index fb899c7026..744aa39c3e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1036,7 +1036,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { _ => 380.0, }; let window = egui::Window::new(app.ui.language.tr(&d.title)) - .id(egui::Id::new("dialog").with(&d.name)) + .id(app.widget_id("dialog").with(&d.name)) .collapsible(false) .resizable(false) .default_width(width) @@ -1185,7 +1185,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { ui.label(format!("Do you want to save the changes you made to {}?", d.values.get("title").and_then(Json::as_str).unwrap_or("this workbook"))); ui.horizontal(|ui| { if ui.button("Don't Save").clicked() { - app.run_or_alert("file.close", json!({"force": true})); + app.close_document(app.session.active_index(), true); open = false; } if ui.button("Cancel").clicked() { @@ -1195,7 +1195,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { if ui.button("Save").clicked() && app.commit_edit(0, 0, false, false) { app.run_or_alert("file.save", json!({})); if app.session.active().is_some_and(|x| !x.is_dirty()) { - app.run_or_alert("file.close", json!({"force": true})); + app.close_document(app.session.active_index(), true); } open = false; } diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index 7f1ad87fc1..3dfd7e2ae3 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -12,7 +12,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let expanded = app.ui.formula_bar_expanded; let h = if expanded { 72.0 } else { 30.0 }; - egui::Panel::top("formula_bar") + egui::Panel::top(app.widget_id("formula_bar")) .exact_size(h) .frame(egui::Frame::NONE.fill(t.window).inner_margin(egui::Margin { left: 8, right: 8, top: 3, bottom: 3 })) .show(ui, |ui| { @@ -38,7 +38,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { ui.painter().rect_filled(field, 4.0, t.input_bg); ui.painter().rect_stroke(field, 4.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(field.shrink2(vec2(6.0, 3.0)))); - let id = egui::Id::new("gridcraft.formula_bar"); + let id = app.widget_id("gridcraft.formula_bar"); let editing_here = app.editor.as_ref().is_some_and(|e| e.from_formula_bar); if app.editor.is_some() { editor_widget(app, &mut child, id, theme::ui_font(13.0), expanded, field.width() - 12.0); @@ -96,7 +96,7 @@ fn active_input(app: &SheetApp) -> String { } fn name_box(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) { - let id = egui::Id::new("gridcraft.name_box"); + let id = app.widget_id("gridcraft.name_box"); let shown = app.name_box_text(); let mut text = app.name_box.clone().unwrap_or(shown.clone()); let rect = Rect::from_min_size(ui.cursor().min, vec2(118.0, 24.0)); @@ -157,7 +157,7 @@ fn name_box(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) { /// The text editor used in the cell and in the formula bar (they share `app.editor`). pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: FontId, multiline: bool, width: f32) { let Some(mut ed) = app.editor.take() else { return }; - let mine = ed.from_formula_bar == (id == egui::Id::new("gridcraft.formula_bar")); + let mine = ed.from_formula_bar == (id == app.widget_id("gridcraft.formula_bar")); // Keys the editor handles itself (before the TextEdit sees them), only where it has focus. let focused = ui.ctx().memory(|m| m.focused()); let has_focus = focused == Some(id) || (mine && (ed.request_focus || focused.is_none())); @@ -259,7 +259,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: // The text widget. let refs = formula_refs(&ed.text); let text_color = Tokens::get(ui.ctx()).text; - let cell_text_color = if id == egui::Id::new("gridcraft.cell_editor") { Color32::BLACK } else { text_color }; + let cell_text_color = if id == app.widget_id("gridcraft.cell_editor") { Color32::BLACK } else { text_color }; let font2 = font.clone(); let mut layouter = move |ui: &egui::Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| { let s = buf.as_str(); @@ -302,7 +302,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: output.state.store(ui.ctx(), id); } else if resp.clicked() || resp.gained_focus() { // Clicking into the other editor moves editing there. - ed.from_formula_bar = id == egui::Id::new("gridcraft.formula_bar"); + ed.from_formula_bar = id == app.widget_id("gridcraft.formula_bar"); ed.enter_mode = false; app.session.mode = gridcraft_engine::Mode::Edit; } diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 97b787ef33..f4e9bc3901 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2567,7 +2567,7 @@ fn select_ranges(app: &mut SheetApp, r: RangeRef, add: bool, active: CellRef) { /// Grid keyboard handling when no text field has focus. fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: &Geo, sh: &Sheet) { // Focus left on an editor that no longer exists goes back to the grid. - let stale = [egui::Id::new("gridcraft.cell_editor"), egui::Id::new("gridcraft.formula_bar"), crate::text_box::editor_id()]; + let stale = [app.widget_id("gridcraft.cell_editor"), app.widget_id("gridcraft.formula_bar"), crate::text_box::editor_id(app)]; let editing = app.editor.is_some() || app.text_box_editor.is_some(); if !editing && ctx.memory(|m| m.focused()).is_some_and(|f| stale.contains(&f)) { resp.request_focus(); @@ -2721,7 +2721,7 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, let painter = ui.painter().with_clip_rect(clip); painter.rect_filled(erect, 0.0, fill); painter.rect_stroke(erect.expand(1.0), 0.0, Stroke::new(2.0, Tokens::get(ui.ctx()).sel_border), StrokeKind::Middle); - let id = egui::Id::new("gridcraft.cell_editor"); + let id = app.widget_id("gridcraft.cell_editor"); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(erect.shrink2(vec2(2.0, 1.0)))); child.set_clip_rect(clip); crate::formula_bar::editor_widget(app, &mut child, id, font, false, erect.width() - 4.0); @@ -2766,10 +2766,10 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) } let wb = app.session.active().map(|d| d.wb.clone()).unwrap_or(wb); let values = gridcraft_engine::cmd::data::filter_values(&wb, si, col); - let key = egui::Id::new(("filter_sel", col)); - let search_key = egui::Id::new(("filter_search", col)); + let key = app.widget_id(("filter_sel", col)); + let search_key = app.widget_id(("filter_search", col)); let mut close = false; - let area = egui::Area::new(egui::Id::new("filter_menu")).fixed_pos(at + vec2(-180.0, 10.0)).order(egui::Order::Foreground); + let area = egui::Area::new(app.widget_id("filter_menu")).fixed_pos(at + vec2(-180.0, 10.0)).order(egui::Order::Foreground); let response = area.show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_width(230.0); @@ -2844,7 +2844,7 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { let Some(at) = app.grid.context_menu else { return }; let mut close = false; - let resp = egui::Area::new(egui::Id::new("cell_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + let resp = egui::Area::new(app.widget_id("cell_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_min_width(200.0); let insert_copied = insert_copied_item(app); @@ -2991,7 +2991,7 @@ pub(crate) fn paint_overlays( { let r = geo.cell_rect(sh, sel.active); let (at, pivot) = (geo.mirror_pos(pos2(r.left() + 8.0, r.bottom() + 6.0)), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP }); - egui::Area::new(egui::Id::new("dv_input")).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show(ui.ctx(), |ui| { + egui::Area::new(app.widget_id("dv_input")).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).fill(Color32::from_rgb(0xFF, 0xFF, 0xE1)).show(ui, |ui| { ui.set_max_width(220.0); if !dv.input_title.is_empty() { @@ -3016,7 +3016,7 @@ pub(crate) fn paint_overlays( let at = pos2(r.right() + 10.0, r.top()); p.line_segment([r.right_top(), at + vec2(0.0, 6.0)], Stroke::new(1.0, Color32::from_gray(90))); let (at, pivot) = (geo.mirror_pos(at), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP }); - egui::Area::new(egui::Id::new(("note", c.row, c.col))).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show( + egui::Area::new(app.widget_id(("note", c.row, c.col))).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show( ui.ctx(), |ui| { egui::Frame::popup(ui.style()).fill(if cm.threaded { Color32::WHITE } else { Color32::from_rgb(0xFF, 0xFF, 0xE1) }).show(ui, |ui| { @@ -3036,7 +3036,7 @@ pub(crate) fn paint_overlays( && app.editor.is_none() { let tip = h.tooltip.clone().unwrap_or_else(|| format!("{}\nClick once to follow. Click and hold to select this cell.", h.target)); - egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("link_tip"), egui::PopupAnchor::Pointer).show(|ui| { + egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), app.widget_id("link_tip"), egui::PopupAnchor::Pointer).show(|ui| { ui.label(tip); }); } @@ -3083,7 +3083,7 @@ fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) let r = geo.cell_rect(sh, c); let (at, pivot) = (geo.mirror_pos(r.left_bottom()), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP }); let mut close = false; - let response = egui::Area::new(egui::Id::new("dv_list")).fixed_pos(at).pivot(pivot).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + let response = egui::Area::new(app.widget_id("dv_list")).fixed_pos(at).pivot(pivot).order(egui::Order::Foreground).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).inner_margin(2.0).show(ui, |ui| { ui.set_min_width(r.width().max(100.0)); egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| { @@ -3104,7 +3104,7 @@ fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { let Some((at, rows)) = app.grid.header_menu else { return }; let mut close = false; - let response = egui::Area::new(egui::Id::new("header_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + let response = egui::Area::new(app.widget_id("header_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_min_width(190.0); let insert_copied = insert_copied_item(app); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 3e5cdf3794..6c57a1e117 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -57,6 +57,7 @@ pub enum Icon { ChevronUp, Close, Check, + Eye, Fx, Plus, Left, @@ -445,6 +446,21 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) { g.rect(2.0, 2.0, 12.0, 12.0, None, Some(c)); g.line(&[(4.5, 8.0), (7.0, 10.5), (11.5, 5.0)], c, 1.4); } + Icon::Eye => { + g.p.add(CubicBezierShape::from_points_stroke( + [g.pt(2.0, 8.0), g.pt(5.0, 3.5), g.pt(11.0, 3.5), g.pt(14.0, 8.0)], + false, + Color32::TRANSPARENT, + Stroke::new(1.2, c), + )); + g.p.add(CubicBezierShape::from_points_stroke( + [g.pt(14.0, 8.0), g.pt(11.0, 12.5), g.pt(5.0, 12.5), g.pt(2.0, 8.0)], + false, + Color32::TRANSPARENT, + Stroke::new(1.2, c), + )); + g.circle(8.0, 8.0, 2.0, Some(c), None); + } Icon::Fx => g.text(8.0, 8.0, "fx", 10.5, c, false, true), Icon::Plus => { g.line(&[(8.0, 3.0), (8.0, 13.0)], c, 1.3); @@ -720,6 +736,7 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) { /// Icons offered by Insert › Icons, by name. pub const LIBRARY: &[(&str, Icon)] = &[ ("check", Icon::Check), + ("eye", Icon::Eye), ("close", Icon::Close), ("plus", Icon::Plus), ("minus", Icon::Minus), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f96cdac3c0..99640ccc36 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -44,6 +44,22 @@ use serde_json::{Value as Json, json}; pub use control::{ControlRequest, ControlResponse}; pub mod desktop_theme; +/// One native workbook window shown by the desktop shell. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WorkbookWindowInfo { + pub id: u64, + pub title: String, + pub dirty: bool, + pub active: bool, +} + +/// An action requested from the workbook switcher in a native window title bar. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WorkbookWindowAction { + Focus(u64), + Close(u64), +} + /// Persisted UI preferences. #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -761,14 +777,25 @@ impl SheetApp { } } - pub fn open_path(&mut self, path: &str) { - match self.run("file.open", json!({"path": path})) { - Ok(r) => self.after_open(Some(path), &r), + pub fn open_path(&mut self, path: &str) -> bool { + let opened = match self.run("file.open", json!({"path": path})) { + Ok(r) => { + self.after_document_change(); + self.after_open(Some(path), &r); + true + } // A password-protected workbook: ask for the password; the dialog opens it. - Err(e) if e.contains(gridcraft_engine::io::PASSWORD_REQUIRED) => self.open_dialog("openPassword", json!({"path": path})), - Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))), - } + Err(e) if e.contains(gridcraft_engine::io::PASSWORD_REQUIRED) => { + self.open_dialog("openPassword", json!({"path": path})); + false + } + Err(e) => { + self.message = Some(("GridCraft".into(), clean_error(&e))); + false + } + }; self.after_engine(); + opened } /// What follows a successful `file.open`: the workbook shows (not the Backstage), the Recent @@ -815,10 +842,49 @@ impl SheetApp { } } + /// Switches the workbook shown by this app instance and clears transient state from the + /// previous workbook. Callers should use this instead of invoking `window.activate` directly. + pub fn activate_document(&mut self, index: usize) { + if index == self.session.active_index() { + return; + } + if self.session.run("window.activate", json!({"index": index})).is_ok() { + self.after_document_change(); + } + self.after_engine(); + } + + /// Closes one of the workbooks shown by the workbook switcher. Dirty workbooks use the + /// existing Save Changes dialog unless `force` is set by that dialog's confirmed action. + pub fn close_document(&mut self, index: usize, force: bool) { + if index >= self.session.documents().len() { + return; + } + self.activate_document(index); + match self.run("file.close", json!({"force": force})) { + Ok(result) if result.get("closed").and_then(Json::as_bool) == Some(true) => self.after_document_change(), + Ok(_) => {} + Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))), + } + } + + fn after_document_change(&mut self) { + self.cancel_edit(); + self.selected_chart = None; + self.name_box = None; + self.grid.ensure_visible = true; + } + pub fn view_key(&self) -> Option<(u64, usize)> { self.session.active().map(|d| (d.uid, d.wb.active_sheet)) } + /// Stable egui state scoped to the active workbook. A shared egui context must not let + /// editors, dialogs, overlays, or resizable panels leak state between workbooks. + pub fn widget_id(&self, source: impl std::hash::Hash + std::fmt::Debug) -> egui::Id { + egui::Id::new(("workbook", self.session.active().map(|document| document.uid).unwrap_or(0))).with(source) + } + pub fn view(&self) -> SheetView { self.view_key().and_then(|k| self.views.get(&k).copied()).unwrap_or_else(|| self.saved_view()) } @@ -990,6 +1056,16 @@ impl SheetApp { /// Per-frame logic (control channel, screenshots). Call before `ui`. pub fn logic(&mut self, ctx: &egui::Context) { + self.logic_inner(ctx, true); + } + + /// Per-frame logic for a desktop window whose lifetime is managed by the native shell. + /// Unlike [`Self::logic`], this leaves an emptied session empty so the shell can close it. + pub fn logic_for_workbook_window(&mut self, ctx: &egui::Context) { + self.logic_inner(ctx, false); + } + + fn logic_inner(&mut self, ctx: &egui::Context, ensure_document: bool) { // A keytip control request lives one frame: the ribbon consumes it during `ui`, and // anything that never reached a control (collapsed ribbon, tab switch) is dropped. self.ui.keytip_invoke = None; @@ -1079,7 +1155,7 @@ impl SheetApp { } self.after_engine(); } - if self.session.active().is_none() { + if ensure_document && self.session.active().is_none() { self.session.new_workbook(); } } @@ -1092,11 +1168,16 @@ impl SheetApp { /// Lays out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { + let _ = self.ui_with_workbook_windows(ui, None); + } + + /// Lays out a native workbook window and reports actions for the desktop window manager. + pub fn ui_with_workbook_windows(&mut self, ui: &mut egui::Ui, workbooks: Option<&[WorkbookWindowInfo]>) -> Option { let ctx = ui.ctx().clone(); self.poll_printing(&ctx); if !self.fonts_ready || self.fonts_rebind { ctx.request_repaint(); - return; + return None; } let t0 = now_ms(); text_box::before_ui(self, &ctx); @@ -1106,7 +1187,7 @@ impl SheetApp { if self.backstage.is_none() { self.ribbon_keys(&ctx); } - ribbon::title_bar(self, ui); + let window_action = ribbon::title_bar(self, ui, workbooks); if self.backstage.is_some() { // File tab: the Backstage view replaces everything below the title bar. backstage::show(self, ui); @@ -1164,6 +1245,7 @@ impl SheetApp { self.grid.last_title = Some(title); } } + window_action } fn ribbon_keys(&mut self, ctx: &egui::Context) { diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index aaa9bec32a..97351e946f 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -17,9 +17,10 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { } let Some(pane) = app.grid.pane.clone().or_else(|| app.session.comments_pane.then(|| "comments".to_string())) else { return }; let t = Tokens::get(ui.ctx()); + let panel_id = app.widget_id("task_pane"); // Capped at half the window so a pane can never cover the sheet, whatever its content asks for. let max = (ui.available_width() / 2.0).max(300.0); - let panel = egui::Panel::right("task_pane").default_size(300.0).max_size(max).resizable(true); + let panel = egui::Panel::right(panel_id).default_size(300.0).max_size(max).resizable(true); panel.frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(10)).show(ui, |ui| { ui.horizontal(|ui| { let title = match pane.as_str() { @@ -79,7 +80,7 @@ fn comments(app: &mut SheetApp, ui: &mut Ui) { for (a, txt) in &c.replies { ui.label(egui::RichText::new(format!("↳ {a}: {txt}")).color(Color32::from_gray(60))); } - let key = egui::Id::new(("reply", &cell)); + let key = app.widget_id(("reply", &cell)); let mut draft: String = ui.ctx().data_mut(|m| m.get_temp(key)).unwrap_or_default(); // Buttons first, right to left, so the reply field gets exactly the width they leave: // a field sized by a guess wider than the row pushes the resizable pane wider every frame. @@ -171,7 +172,7 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) { }; let theme = app.session.active().map(|d| d.wb.theme.clone()).unwrap_or_default(); ui.collapsing("Chart Title", |ui| { - let field = egui::Id::new(("gridcraft.chart_title", id)); + let field = app.widget_id(("gridcraft.chart_title", id)); if let Some(title) = crate::widgets::committed_text(ui, field, chart.title.as_deref().unwrap_or(""), None) { app.run_or_alert("chart.set", json!({"chart": id, "title": title})); } @@ -216,14 +217,14 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) { } ui.horizontal(|ui| { ui.label("Horizontal axis title:"); - let field = egui::Id::new(("gridcraft.chart_x_title", id)); + let field = app.widget_id(("gridcraft.chart_x_title", id)); if let Some(xt) = crate::widgets::committed_text(ui, field, chart.x_title.as_deref().unwrap_or(""), None) { app.run_or_alert("chart.set", json!({"chart": id, "xTitle": if xt.is_empty() { Json::Null } else { json!(xt) }})); } }); ui.horizontal(|ui| { ui.label("Vertical axis title:"); - let field = egui::Id::new(("gridcraft.chart_y_title", id)); + let field = app.widget_id(("gridcraft.chart_y_title", id)); if let Some(yt) = crate::widgets::committed_text(ui, field, chart.y_title.as_deref().unwrap_or(""), None) { app.run_or_alert("chart.set", json!({"chart": id, "yTitle": if yt.is_empty() { Json::Null } else { json!(yt) }})); } @@ -270,7 +271,7 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) { }); ui.collapsing("Size", |ui| { // Keep the value being typed or dragged between frames; reloading it from the chart every frame threw the edit away. - let draft = egui::Id::new(("gridcraft.chart_size_draft", id)); + let draft = app.widget_id(("gridcraft.chart_size_draft", id)); let (mut w, mut h) = ui.data_mut(|d| d.get_temp::<(f32, f32)>(draft)).unwrap_or((chart.anchor.width, chart.anchor.height)); let a = ui.add(egui::DragValue::new(&mut w).prefix("Width ").range(40.0..=4000.0)); let b = ui.add(egui::DragValue::new(&mut h).prefix("Height ").range(40.0..=4000.0)); diff --git a/crates/ui-egui/src/paper_view.rs b/crates/ui-egui/src/paper_view.rs index 621a979033..7f58cfe0a3 100644 --- a/crates/ui-egui/src/paper_view.rs +++ b/crates/ui-egui/src/paper_view.rs @@ -476,7 +476,7 @@ fn editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid) { app.grid.hf_edit = Some(e); return; }; - let id = egui::Id::new("gridcraft.header_footer_editor"); + let id = app.widget_id("gridcraft.header_footer_editor"); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(3.0, 2.0)).intersect(geo.cells))); let align = [egui::Align::LEFT, egui::Align::Center, egui::Align::RIGHT][e.section.min(2)]; if let Some(text) = e.texts.get_mut(e.section) { diff --git a/crates/ui-egui/src/pivot_pane.rs b/crates/ui-egui/src/pivot_pane.rs index 55917ef176..f28c675d81 100644 --- a/crates/ui-egui/src/pivot_pane.rs +++ b/crates/ui-egui/src/pivot_pane.rs @@ -25,7 +25,8 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { Ok(v) => v, Err(_) => return, }; - egui::Panel::right("pivot_pane").default_size(280.0).resizable(true).frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(10)).show(ui, |ui| { + let panel_id = app.widget_id("pivot_pane"); + egui::Panel::right(panel_id).default_size(280.0).resizable(true).frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(10)).show(ui, |ui| { ui.label(egui::RichText::new(lang.tr("PivotTable Fields")).font(theme::ui_bold(15.0))); ui.label(egui::RichText::new(lang.tr("Choose fields to add to report:")).small().color(t.text_dim)); ui.add_space(4.0); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 9c6d89eeb2..2c7843af09 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1,14 +1,14 @@ //! Title bar (Quick Access Toolbar), ribbon tabs and ribbon groups, and keyboard shortcuts. -use egui::{Align2, Color32, Key, Modifiers, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use egui::{Align2, Color32, CursorIcon, Key, Modifiers, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use gridcraft_engine::model::{HAlign, Style, VAlign}; use serde_json::json; -use crate::SheetApp; use crate::icons::{self, Icon}; use crate::keytips::keytip_table; use crate::theme::{self, Tokens}; use crate::widgets::{big_button, color_palette, icon_button, small_button, split_button, toggle_button}; +use crate::{SheetApp, WorkbookWindowAction, WorkbookWindowInfo}; pub const TABS: &[&str] = &["Home", "Insert", "Draw", "Page Layout", "Formulas", "Data", "Review", "View", "Automate"]; @@ -35,10 +35,11 @@ const TITLE_BAR_H: f32 = if MAC { MAC_TITLE_BAR } else { 38.0 }; const TITLE_CONTROL_H: f32 = if MAC { 22.0 } else { 26.0 }; const _: () = assert!(TITLE_CONTROL_H < TITLE_BAR_H, "title-bar controls keep a margin above and below"); -pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { +pub fn title_bar(app: &mut SheetApp, ui: &mut Ui, workbooks: Option<&[WorkbookWindowInfo]>) -> Option { let t = Tokens::get(ui.ctx()); let ch = TITLE_CONTROL_H; - egui::Panel::top("title_bar").exact_size(TITLE_BAR_H).frame(egui::Frame::NONE.fill(t.window)).show(ui, |ui| { + let mut window_action = None; + egui::Panel::top(app.widget_id("title_bar")).exact_size(TITLE_BAR_H).frame(egui::Frame::NONE.fill(t.window)).show(ui, |ui| { let rect = ui.max_rect(); // Dragging the empty title area moves the window. let bg = ui.interact(rect, ui.id().with("drag_title"), Sense::click_and_drag()); @@ -112,16 +113,7 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { app.print_now(json!({})); } }); - // Centered title. - let title = app.session.active().map(|d| d.display_title()).unwrap_or_default(); - let dirty = app.session.active().is_some_and(|d| d.is_dirty()); - ui.painter().text( - rect.center(), - Align2::CENTER_CENTER, - format!("{title}{}", if dirty { " •" } else { "" }), - theme::ui_bold(13.0), - t.text, - ); + window_action = workbook_switcher(app, ui, rect, &t, workbooks); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.add_space(8.0); let __k = icon_button(ui, Icon::Search, t.text_dim, "Search commands (⌘⇧Space)", vec2(28.0, ch)); @@ -160,13 +152,106 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) { }); }); }); + window_action +} + +fn workbook_switcher( + app: &mut SheetApp, + ui: &mut Ui, + title_bar: Rect, + t: &Tokens, + workbooks: Option<&[WorkbookWindowInfo]>, +) -> Option { + let documents: Vec<(u64, usize, String, bool, bool)> = match workbooks { + Some(workbooks) => workbooks.iter().map(|w| (w.id, 0, w.title.clone(), w.active, w.dirty)).collect(), + None => app + .session + .documents() + .iter() + .enumerate() + .map(|(i, d)| (d.uid, i, d.display_title(), i == app.session.active_index(), d.is_dirty())) + .collect(), + }; + let (_, _, title, _, dirty) = documents.iter().find(|(_, _, _, active, _)| *active)?; + let multiple = documents.len() > 1; + let open_count = if multiple { format!(" ({} open)", documents.len()) } else { String::new() }; + let text = format!("{title}{}{}", if *dirty { " — Unsaved" } else { "" }, open_count); + let title_color = if *dirty { t.warning } else { t.text }; + let width = (documents + .iter() + .map(|(_, _, title, _, dirty)| { + let candidate = format!("{title}{}{}", if *dirty { " — Unsaved" } else { "" }, open_count); + ui.painter().layout_no_wrap(candidate, theme::ui_bold(13.0), title_color).size().x + }) + .fold(0.0_f32, f32::max) + + 20.0) + .clamp(100.0, (title_bar.width() * 0.42).max(100.0)); + let title_rect = Rect::from_center_size(title_bar.center(), vec2(width, 28.0)); + let response = + ui.put(title_rect, egui::Button::new(egui::RichText::new(text).font(theme::ui_bold(13.0)).color(title_color)).frame(false).truncate()); + if !multiple { + return None; + } + let mut action = None; + let response = response.on_hover_text(format!("Switch workbook ({} open)", documents.len())); + egui::Popup::menu(&response).show(|ui| { + ui.spacing_mut().item_spacing = vec2(4.0, 2.0); + let title_width = documents + .iter() + .map(|(_, _, title, _, _)| ui.painter().layout_no_wrap(title.clone(), theme::ui_font(13.0), t.text).size().x) + .fold(0.0_f32, f32::max) + .clamp(120.0, 190.0); + let row_width = 20.0 + title_width + 56.0 + 22.0 + 12.0; + for (id, index, title, active, dirty) in &documents { + let (row_rect, row) = ui.allocate_exact_size(vec2(row_width, 24.0), Sense::click()); + let close_rect = Rect::from_min_size(pos2(row_rect.right() - 22.0, row_rect.top() + 1.0), vec2(22.0, 22.0)); + if ui.rect_contains_pointer(row_rect) { + ui.painter().rect_filled(row_rect, 4.0, t.hover); + ui.ctx().set_cursor_icon(CursorIcon::PointingHand); + } + let eye_rect = Rect::from_center_size(pos2(row_rect.left() + 7.0, row_rect.center().y), vec2(14.0, 14.0)); + if *active { + icons::paint(ui.painter(), eye_rect, Icon::Eye, t.text); + } + let title_rect = Rect::from_min_size(pos2(row_rect.left() + 20.0, row_rect.top()), vec2(title_width, row_rect.height())); + ui.painter().with_clip_rect(title_rect).text(title_rect.left_center(), Align2::LEFT_CENTER, title, theme::ui_font(13.0), t.text); + if *dirty { + ui.painter().text( + pos2(title_rect.right() + 4.0, row_rect.center().y), + Align2::LEFT_CENTER, + "Unsaved", + theme::ui_font(13.0), + t.warning, + ); + } + let close_clicked = ui + .scope_builder(egui::UiBuilder::new().max_rect(close_rect), |ui| { + icon_button(ui, Icon::Close, t.text_dim, &format!("Close {title}"), vec2(22.0, 22.0)).clicked() + }) + .inner; + if close_clicked { + if workbooks.is_some() { + action = Some(WorkbookWindowAction::Close(*id)); + } else { + app.close_document(*index, false); + } + } else if row.clicked() { + if workbooks.is_some() { + action = Some(WorkbookWindowAction::Focus(*id)); + } else { + app.activate_document(*index); + } + } + } + }); + action } pub fn show(app: &mut SheetApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let collapsed = app.ui.ribbon_collapsed; let h = if collapsed { 34.0 } else { 34.0 + 92.0 }; - egui::Panel::top("ribbon") + egui::Panel::top(app.widget_id("ribbon")) .exact_size(h) .frame(egui::Frame::NONE.fill(t.window).inner_margin(egui::Margin { left: 10, right: 10, top: 0, bottom: 6 })) .show(ui, |ui| { @@ -324,7 +409,7 @@ fn act(app: &mut SheetApp, id: &str, params: serde_json::Value) { fn keytip(app: &SheetApp, ui: &Ui, sequence: &str, at: egui::Pos2) { let Some(rest) = app.keytips.prefix().and_then(|prefix| sequence.strip_prefix(prefix)).filter(|s| !s.is_empty()) else { return }; let t = Tokens::get(ui.ctx()); - let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("ribbon_keytips"))).with_clip_rect(ui.clip_rect()); + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, app.widget_id("ribbon_keytips"))).with_clip_rect(ui.clip_rect()); let text = painter.layout_no_wrap(rest.into(), theme::ui_bold(10.5), t.text); let rect = Rect::from_center_size(at, text.size() + vec2(7.0, 3.0)); painter.rect_filled(rect, 2.0, t.input_bg); @@ -2275,7 +2360,7 @@ fn table_design(app: &mut SheetApp, ui: &mut Ui) { }; ui.vertical(|ui| { ui.label(egui::RichText::new("Table Name:").small()); - let field = egui::Id::new(("gridcraft.table_name", &tname)); + let field = app.widget_id(("gridcraft.table_name", &tname)); if let Some(name) = crate::widgets::committed_text(ui, field, &tname, Some(120.0)) && name != tname { @@ -2419,7 +2504,10 @@ pub fn shortcut(app: &mut SheetApp, key: Key, m: Modifiers) { Key::S => Some(("file.save", json!({}))), Key::O => Some(("file.open", json!({}))), Key::N => Some(("file.new", json!({}))), - Key::W => Some(("file.close", json!({}))), + Key::W => { + app.close_document(app.session.active_index(), false); + None + } Key::P => { app.print_now(json!({})); None diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index b77524bb96..17b73dfa1c 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -37,7 +37,7 @@ fn activate_sheet(app: &mut SheetApp, i: usize) { pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - egui::Panel::bottom("sheet_tabs").exact_size(32.0).frame(egui::Frame::NONE.fill(t.tab_bar)).show(ui, |ui| { + egui::Panel::bottom(app.widget_id("sheet_tabs")).exact_size(32.0).frame(egui::Frame::NONE.fill(t.tab_bar)).show(ui, |ui| { let rect = ui.max_rect(); ui.painter().line_segment([rect.left_top(), rect.right_top()], Stroke::new(1.0, t.ribbon_border)); ui.horizontal_centered(|ui| { @@ -226,9 +226,10 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - egui::Panel::bottom("status_bar").exact_size(26.0).frame(egui::Frame::NONE.fill(t.status_bar).inner_margin(egui::Margin::symmetric(12, 0))).show( - ui, - |ui| { + egui::Panel::bottom(app.widget_id("status_bar")) + .exact_size(26.0) + .frame(egui::Frame::NONE.fill(t.status_bar).inner_margin(egui::Margin::symmetric(12, 0))) + .show(ui, |ui| { ui.horizontal_centered(|ui| { let mode = match app.session.mode { gridcraft_engine::Mode::Ready => "Ready", @@ -334,8 +335,7 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) { } }); }); - }, - ); + }); } #[cfg(test)] diff --git a/crates/ui-egui/src/text_box.rs b/crates/ui-egui/src/text_box.rs index a0b30c0252..ca005b4249 100644 --- a/crates/ui-egui/src/text_box.rs +++ b/crates/ui-egui/src/text_box.rs @@ -17,8 +17,8 @@ pub struct EditState { rect: Option, } -pub(crate) fn editor_id() -> egui::Id { - egui::Id::new("gridcraft.text_box_editor") +pub(crate) fn editor_id(app: &SheetApp) -> egui::Id { + app.widget_id("gridcraft.text_box_editor") } impl SheetApp { @@ -94,13 +94,13 @@ pub(crate) fn before_ui(app: &mut SheetApp, ctx: &egui::Context) { let Some(ed) = &app.text_box_editor else { return }; if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) { app.cancel_text_box_edit(); - ctx.memory_mut(|m| m.surrender_focus(editor_id())); + ctx.memory_mut(|m| m.surrender_focus(editor_id(app))); return; } let outside = ctx.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| ed.rect.is_none_or(|r| !r.contains(p)))); if outside { match app.commit_text_box_edit() { - Ok(()) => ctx.memory_mut(|m| m.surrender_focus(editor_id())), + Ok(()) => ctx.memory_mut(|m| m.surrender_focus(editor_id(app))), Err(e) => app.message = Some(("Text Box".into(), crate::clean_error(&e))), } } @@ -139,7 +139,7 @@ pub(crate) fn show(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &gridcr return; } ed.rect = Some(rect); - let id = editor_id(); + let id = editor_id(app); if ed.request_focus { // A new editing session must not reuse the previous box's caret/undo history. egui::text_edit::TextEditState::default().store(ui.ctx(), id); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 485b9a2f37..fb928584cd 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -50,6 +50,7 @@ pub struct Tokens { pub notice_warn_border: Color32, pub notice_info_bg: Color32, pub notice_info_border: Color32, + pub warning: Color32, } impl Tokens { @@ -92,6 +93,7 @@ impl Tokens { notice_warn_border: Color32::from_rgb(0xF0, 0xC3, 0x6E), notice_info_bg: Color32::from_rgb(0xEA, 0xF2, 0xFB), notice_info_border: Color32::from_rgb(0xB9, 0xD2, 0xEC), + warning: Color32::from_rgb(0x8A, 0x68, 0x00), } } @@ -134,6 +136,7 @@ impl Tokens { notice_warn_border: Color32::from_rgb(0x7A, 0x67, 0x30), notice_info_bg: Color32::from_rgb(0x24, 0x33, 0x44), notice_info_border: Color32::from_rgb(0x3B, 0x55, 0x72), + warning: Color32::from_rgb(0xE0, 0xB8, 0x42), } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index e24105b498..ae7c4d993d 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -273,7 +273,7 @@ pub fn color_palette(ui: &mut Ui, theme_colors: &[u32; 12], none_label: &str) -> pub fn message_box(app: &mut SheetApp, ctx: &egui::Context) { let Some((title, msg)) = app.message.clone() else { return }; let mut close = false; - egui::Modal::new(egui::Id::new("message_box")).show(ctx, |ui| { + egui::Modal::new(app.widget_id("message_box")).show(ctx, |ui| { ui.set_width(380.0); ui.horizontal(|ui| { let (r, _) = ui.allocate_exact_size(vec2(36.0, 36.0), Sense::hover()); @@ -303,7 +303,7 @@ pub fn toast(app: &mut SheetApp, ctx: &egui::Context) { return; } let Some(rect) = app.grid.rect else { return }; - egui::Area::new(egui::Id::new("toast")).fixed_pos(pos2(rect.center().x - 160.0, rect.bottom() - 48.0)).order(egui::Order::Tooltip).show( + egui::Area::new(app.widget_id("toast")).fixed_pos(pos2(rect.center().x - 160.0, rect.bottom() - 48.0)).order(egui::Order::Tooltip).show( ctx, |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { @@ -326,7 +326,7 @@ pub fn notice_bar(app: &mut SheetApp, ui: &mut Ui) { }; let mut dismiss = false; let mut action: Option<(String, String)> = None; - egui::Panel::top("notice_bar").frame(egui::Frame::NONE.fill(bg).inner_margin(egui::Margin::symmetric(12, 0))).show(ui, |ui| { + egui::Panel::top(app.widget_id("notice_bar")).frame(egui::Frame::NONE.fill(bg).inner_margin(egui::Margin::symmetric(12, 0))).show(ui, |ui| { // Bottom border as a 1px strip spanning the bar. let bar = ui.max_rect(); ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, border), StrokeKind::Inside); diff --git a/crates/ui-egui/tests/formula_popup.rs b/crates/ui-egui/tests/formula_popup.rs index 94886144b5..6027bae4b2 100644 --- a/crates/ui-egui/tests/formula_popup.rs +++ b/crates/ui-egui/tests/formula_popup.rs @@ -1,5 +1,5 @@ //! Formula helpers must stay in the worksheet while its editor scrolls. -use egui::{Context, Id, LayerId, Order, RawInput, Rect, pos2, vec2}; +use egui::{Context, LayerId, Order, RawInput, Rect, pos2, vec2}; use gridcraft_engine::Session; use gridcraft_ui_egui::SheetApp; @@ -20,8 +20,8 @@ fn frames(ctx: &Context, app: &mut SheetApp) { } } -fn visible(ctx: &Context, editor: &str) -> bool { - ctx.memory(|m| m.areas().is_visible(&LayerId::new(Order::Tooltip, Id::new(editor).with("ac")))) +fn visible(ctx: &Context, app: &SheetApp, editor: &str) -> bool { + ctx.memory(|m| m.areas().is_visible(&LayerId::new(Order::Tooltip, app.widget_id(editor).with("ac")))) } #[test] @@ -37,14 +37,14 @@ fn cell_formula_helpers_hide_when_scrolled_out_and_return_with_the_editor() { app.editor.as_mut().unwrap().update_autocomplete(&["SUM".into(), "SUMIF".into()]); } frames(&ctx, &mut app); - assert!(visible(&ctx, "gridcraft.cell_editor"), "visible cell shows {text} help"); + assert!(visible(&ctx, &app, "gridcraft.cell_editor"), "visible cell shows {text} help"); app.view_mut().unwrap().scroll.y = 400.0; frames(&ctx, &mut app); - assert!(!visible(&ctx, "gridcraft.cell_editor"), "scrolled-out cell must hide {text} help"); + assert!(!visible(&ctx, &app, "gridcraft.cell_editor"), "scrolled-out cell must hide {text} help"); assert_eq!(app.editor.as_ref().unwrap().text, text, "scrolling preserves the edit"); app.view_mut().unwrap().scroll.y = 0.0; frames(&ctx, &mut app); - assert!(visible(&ctx, "gridcraft.cell_editor"), "scrolling back restores {text} help"); + assert!(visible(&ctx, &app, "gridcraft.cell_editor"), "scrolling back restores {text} help"); } } @@ -57,10 +57,10 @@ fn formula_bar_hint_remains_visible_when_the_worksheet_scrolls() { frames(&ctx, &mut app); app.begin_edit(Some("=SUM(".into()), true); frames(&ctx, &mut app); - assert!(visible(&ctx, "gridcraft.formula_bar")); + assert!(visible(&ctx, &app, "gridcraft.formula_bar")); app.view_mut().unwrap().scroll.y = 400.0; frames(&ctx, &mut app); - assert!(visible(&ctx, "gridcraft.formula_bar")); + assert!(visible(&ctx, &app, "gridcraft.formula_bar")); } #[test] @@ -123,7 +123,7 @@ fn cell_editor_and_helper_stay_below_the_frozen_top_row() { let scroll = sh.row_top(4) as f32; app.begin_edit(Some("=SUM(".into()), false); frames(&ctx, &mut app); - assert!(visible(&ctx, "gridcraft.cell_editor")); + assert!(visible(&ctx, &app, "gridcraft.cell_editor")); app.view_mut().unwrap().scroll.y = scroll - frozen_h / 2.0; frames(&ctx, &mut app); let output = frame(&ctx, &mut app); @@ -136,11 +136,11 @@ fn cell_editor_and_helper_stay_below_the_frozen_top_row() { assert!(editor.clip_rect.top() >= frozen_bottom, "editor must be clipped below the frozen row: {:?}", editor.clip_rect); app.view_mut().unwrap().scroll.y = scroll; frames(&ctx, &mut app); - assert!(!visible(&ctx, "gridcraft.cell_editor"), "cell hidden under the frozen row must hide its helper"); + assert!(!visible(&ctx, &app, "gridcraft.cell_editor"), "cell hidden under the frozen row must hide its helper"); assert_eq!(app.editor.as_ref().unwrap().text, "=SUM("); app.view_mut().unwrap().scroll.y = 0.0; frames(&ctx, &mut app); - assert!(visible(&ctx, "gridcraft.cell_editor"), "scrolling back restores the helper"); + assert!(visible(&ctx, &app, "gridcraft.cell_editor"), "scrolling back restores the helper"); } #[test] diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/interaction.rs index b40769cf88..d6ac024d96 100644 --- a/crates/ui-egui/tests/interaction.rs +++ b/crates/ui-egui/tests/interaction.rs @@ -213,7 +213,8 @@ fn name_box_enter_goes_to_the_typed_cell_not_the_one_below() { let mut h = harness(blank()); h.state_mut().session.run("selection.set", json!({"cell": "C5"})).unwrap(); h.run_steps(2); - h.ctx.memory_mut(|m| m.request_focus(egui::Id::new("gridcraft.name_box"))); + let name_box = h.state().widget_id("gridcraft.name_box"); + h.ctx.memory_mut(|m| m.request_focus(name_box)); h.run_steps(2); h.input_mut().events.push(Event::Key { key: Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }); h.input_mut().events.push(Event::Text("A1".into())); @@ -641,6 +642,37 @@ fn about_renders_every_tab() { } } +#[test] +fn switching_workbooks_clears_transient_editor_state() { + let mut s = blank(); + s.execute("file.new", json!({})).unwrap(); + let mut h = harness(s); + assert_eq!(h.state().session.documents().len(), 2); + h.state_mut().begin_edit(Some("belongs to Book2".into()), false); + assert!(h.state().editor.is_some()); + + h.state_mut().activate_document(0); + + assert_eq!(h.state().session.active_index(), 0); + assert!(h.state().editor.is_none()); +} + +#[test] +fn closing_workbooks_preserves_dirty_documents_until_confirmed() { + let mut s = blank(); + s.execute("cell.set", json!({"cell": "A1", "input": "unsaved"})).unwrap(); + s.execute("file.new", json!({})).unwrap(); + let mut h = harness(s); + + h.state_mut().close_document(0, false); + assert_eq!(h.state().session.documents().len(), 2); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("saveChanges")); + + h.state_mut().close_document(0, true); + assert_eq!(h.state().session.documents().len(), 1); + assert_eq!(h.state().session.active().map(|d| d.display_title()).as_deref(), Some("Book2")); +} + #[test] fn column_autocomplete_completes_on_enter() { let mut s = blank(); diff --git a/crates/ui-egui/tests/keyboard_shortcuts.rs b/crates/ui-egui/tests/keyboard_shortcuts.rs index c0a1307b3d..da0b94534e 100644 --- a/crates/ui-egui/tests/keyboard_shortcuts.rs +++ b/crates/ui-egui/tests/keyboard_shortcuts.rs @@ -172,7 +172,8 @@ fn alt_down_opens_autofilter_headers_without_changing_the_workbook() { // Opening focuses the existing search box; typing must not edit the header. h.input_mut().events.push(Event::Text("1".into())); h.run_steps(2); - assert_eq!(h.ctx.data(|data| data.get_temp::(egui::Id::new(("filter_search", column)))).as_deref(), Some("1")); + let search_id = h.state().widget_id(("filter_search", column)); + assert_eq!(h.ctx.data(|data| data.get_temp::(search_id)).as_deref(), Some("1")); assert!(h.state().editor.is_none()); assert_eq!(h.state().session.active().unwrap().wb, before); key(&mut h, Key::Escape, Modifiers::NONE); diff --git a/crates/ui-egui/tests/workbook_widget_state.rs b/crates/ui-egui/tests/workbook_widget_state.rs new file mode 100644 index 0000000000..1e339c2332 --- /dev/null +++ b/crates/ui-egui/tests/workbook_widget_state.rs @@ -0,0 +1,19 @@ +//! Fixed egui state belongs to a workbook, even when workbooks share one context. + +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; + +#[test] +fn widget_ids_are_stable_within_a_workbook_and_distinct_between_workbooks() { + let mut session = Session::new(); + session.new_workbook(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + + app.session.set_active(0); + let first = app.widget_id("formula_bar"); + assert_eq!(first, app.widget_id("formula_bar")); + + app.session.set_active(1); + assert_ne!(first, app.widget_id("formula_bar")); +}