diff --git a/ROADMAP.md b/ROADMAP.md index 1189fd721..9c53c82db 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-11 | File tab Backstage view (Home, New, Open, Info, Save As, Print preview, Export, Options) and `file.options` (#174) | | 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed | | 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 | | 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows | diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d52418b4c..16e714aa4 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -1,4 +1,4 @@ -//! File menu: new, open, save, close, export, properties. +//! File menu: new, open, save, close, export, properties, options. use serde_json::{Value as Json, json}; @@ -16,6 +16,15 @@ pub fn specs() -> Vec { cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?}", has_doc, |s, p| export(s, p, "csv")), cmd!(noundo "file.exportHtml", "Save as Web Page", ["File", "Export"], None, "{path}", has_doc, |s, p| export(s, p, "html")), cmd!("file.properties", "Properties", ["File"], None, "{title?, subject?, author?, company?, keywords?, description?}", has_doc, properties), + cmd!( + query "file.options", + "Options", + ["File"], + None, + "{} → {calculation: {mode, iterative, maxIterations, maxChange}} for the active workbook (the interface opens File › Options; change settings with their own commands, e.g. formulas.calculationOptions, view.theme, app.language.set)", + always, + options + ), cmd!(noundo "window.activate", "Switch Window", ["Window"], None, "{index}", has_doc, activate_window), ] } @@ -156,6 +165,22 @@ fn properties(s: &mut Session, p: &Json) -> Result { }) } +/// File › Options: reports the workbook-level settings and asks the interface to show its Options +/// page. Nothing changes here; every setting has its own command. +fn options(s: &mut Session, _: &Json) -> Result { + s.ui_requests.push(crate::UiRequest::Dialog("options".into(), json!({}))); + let calculation = s.active().map(|d| { + let c = &d.wb.calc; + let mode = match c.mode { + gridcraft_model::CalcMode::Automatic => "automatic", + gridcraft_model::CalcMode::AutomaticExceptTables => "automaticExceptTables", + gridcraft_model::CalcMode::Manual => "manual", + }; + json!({"mode": mode, "iterative": c.iterative, "maxIterations": c.max_iterations, "maxChange": c.max_change}) + }); + Ok(json!({"calculation": calculation})) +} + fn activate_window(s: &mut Session, p: &Json) -> Result { let i = u32_param(p, "index").ok_or_else(|| bad("window.activate", "missing `index`"))? as usize; if i >= s.documents().len() { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6bd03d55e..4bc7262cd 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1036,6 +1036,22 @@ fn every_command_survives_empty_params() { } } +#[test] +fn file_options_reports_settings_and_asks_for_the_options_page() { + let mut s = Session::new(); + // No workbook: nothing to report, but still no error. + assert_eq!(s.execute("file.options", json!({})).unwrap(), json!({"calculation": null})); + s.take_ui_requests(); + s.new_workbook(); + s.execute("formulas.calculationOptions", json!({"mode": "manual", "iterative": true})).unwrap(); + let before = s.active().unwrap().wb.clone(); + let r = s.execute("file.options", json!({})).unwrap(); + assert_eq!(r["calculation"]["mode"], "manual"); + assert_eq!(r["calculation"]["iterative"], true); + assert!(matches!(s.take_ui_requests().as_slice(), [crate::UiRequest::Dialog(name, _)] if name == "options")); + assert_eq!(s.active().unwrap().wb, before, "Options never changes the workbook"); +} + fn reopen(s: &mut Session) { let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); let b64 = r["base64"].as_str().unwrap().to_string(); diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs new file mode 100644 index 000000000..c29174017 --- /dev/null +++ b/crates/ui-egui/src/backstage.rs @@ -0,0 +1,828 @@ +//! File tab Backstage view (#174): a full-window page that replaces the ribbon, formula bar, grid +//! and sheet tabs until Esc or the back arrow returns to the sheet. +//! +//! A navigation column on the left picks a page (Home, New, Open, Info, Save As, Print, Export, +//! Options); Save and Close act at once. Every action runs an existing command through +//! [`SheetApp::run`] or an existing dialog through [`SheetApp::open_dialog`]; the state here is +//! UI-only and never saved. Recent workbooks are listed by path only: nothing on disk is touched +//! until the user picks one. + +use egui::{Align2, Color32, Key, Modifiers, Rect, RichText, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use serde_json::{Value as Json, json}; + +use crate::SheetApp; +use crate::i18n::{self, Language}; +use crate::icons::{self, Icon}; +use crate::theme::{self, Tokens}; + +/// Width of the navigation column. +const NAV_W: f32 = 196.0; +/// Width of the content column (pages are left-aligned like a document, not stretched). +const CONTENT_W: f32 = 760.0; + +/// A Backstage page. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Page { + Home, + New, + Open, + Info, + SaveAs, + Print, + Export, + Options, +} + +impl Page { + pub const ALL: [Page; 8] = [Page::Home, Page::New, Page::Open, Page::Info, Page::SaveAs, Page::Print, Page::Export, Page::Options]; + + /// The id `ui.backstage` and `ui.dialog {"name": "backstage", "page": …}` take. + pub fn id(self) -> &'static str { + match self { + Page::Home => "home", + Page::New => "new", + Page::Open => "open", + Page::Info => "info", + Page::SaveAs => "saveAs", + Page::Print => "print", + Page::Export => "export", + Page::Options => "options", + } + } + + pub fn parse(id: &str) -> Option { + let id = id.trim(); + Page::ALL.into_iter().find(|p| p.id().eq_ignore_ascii_case(id)) + } + + /// English label (navigation entry and page heading); translated when drawn. + pub fn label(self) -> &'static str { + match self { + Page::Home => "Home", + Page::New => "New", + Page::Open => "Open", + Page::Info => "Info", + Page::SaveAs => "Save As", + Page::Print => "Print", + Page::Export => "Export", + Page::Options => "Options", + } + } + + fn icon(self) -> Icon { + match self { + Page::Home => Icon::Home, + Page::New => Icon::NewFile, + Page::Open => Icon::OpenFolder, + Page::Info => Icon::Info, + Page::SaveAs => Icon::Save, + Page::Print => Icon::Print, + Page::Export => Icon::Share, + Page::Options => Icon::Settings, + } + } +} + +/// Document properties shown on the Info page: (`file.properties` key, label). +const PROPERTIES: [(&str, &str); 6] = [ + ("title", "Title"), + ("subject", "Subject"), + ("author", "Author"), + ("company", "Company"), + ("keywords", "Keywords"), + ("description", "Description"), +]; + +/// The open Backstage view. +#[derive(Clone, Debug)] +pub struct Backstage { + pub page: Page, + /// Info: property drafts while a field is being edited (committed when it loses focus). + props: [String; 6], + /// Print: the whole workbook rather than the active sheet. + pub print_all: bool, + /// Print: fit each sheet on one page. + pub fit_to_page: bool, + /// Print: the previewed page (0-based). + preview_page: usize, + /// Print: the preview for (document, revision, whole workbook, fit to page). + preview: Option<((u64, u64, bool, bool), Json)>, +} + +impl Backstage { + pub fn new(page: Page) -> Backstage { + Backstage { page, props: Default::default(), print_all: false, fit_to_page: false, preview_page: 0, preview: None } + } +} + +/// What a click asked for, applied after drawing. +#[derive(Default)] +struct Outcome { + close: bool, + page: Option, +} + +/// Draws the Backstage view in place of the ribbon, formula bar, grid and sheet tabs. +pub fn show(app: &mut SheetApp, ui: &mut Ui) { + let Some(mut bs) = app.backstage.take() else { return }; + let ctx = ui.ctx().clone(); + // Esc inside a text field leaves the field; it closes the view only from outside one. + let typing = ctx.text_edit_focused(); + let t = Tokens::get(&ctx); + let lang = app.ui.language; + 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") + .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 })) + .show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 0.0; + if nav_item(ui, Icon::Back, lang.tr("Back"), false, nav_text, nav_sel).clicked() { + out.close = true; + } + ui.add_space(10.0); + for page in [Page::Home, Page::New, Page::Open] { + if nav_item(ui, page.icon(), lang.tr(page.label()), bs.page == page, nav_text, nav_sel).clicked() { + out.page = Some(page); + } + } + nav_separator(ui, nav_text); + if nav_item(ui, Page::Info.icon(), lang.tr("Info"), bs.page == Page::Info, nav_text, nav_sel).clicked() { + out.page = Some(Page::Info); + } + if nav_item(ui, Icon::Save, lang.tr("Save"), false, nav_text, nav_sel).clicked() { + save(app, &mut out); + } + for page in [Page::SaveAs, Page::Print, Page::Export] { + if nav_item(ui, page.icon(), lang.tr(page.label()), bs.page == page, nav_text, nav_sel).clicked() { + out.page = Some(page); + } + } + if nav_item(ui, Icon::Close, lang.tr("Close"), false, nav_text, nav_sel).clicked() { + // A cell still being edited is part of the workbook; Close keeps the unsaved prompt. + if app.commit_edit(0, 0, false, false) { + app.run_or_alert("file.close", json!({})); + out.close = true; + } + } + ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| { + if nav_item(ui, Page::Options.icon(), lang.tr("Options"), bs.page == Page::Options, nav_text, nav_sel).clicked() { + out.page = Some(Page::Options); + } + }); + }); + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.ribbon)).show(ui, |ui| { + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + egui::Frame::NONE.inner_margin(egui::Margin { left: 44, right: 32, top: 26, bottom: 32 }).show(ui, |ui| { + ui.set_max_width(CONTENT_W); + ui.label(RichText::new(lang.tr(bs.page.label())).font(theme::ui_font(28.0)).color(t.text)); + ui.add_space(18.0); + match bs.page { + Page::Home => home(app, ui, &mut out), + Page::New => new_page(app, ui, &mut out), + Page::Open => open_page(app, ui, &mut out), + Page::Info => info(app, ui, &mut bs), + Page::SaveAs => save_as(app, ui, &mut out), + Page::Print => print(app, ui, &mut bs, &mut out), + Page::Export => export(app, ui, &mut out), + Page::Options => options(app, ui), + } + }); + }); + }); + if !typing + && app.dialog.is_none() + && app.message.is_none() + && !egui::Popup::is_any_open(&ctx) + && ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) + { + out.close = true; + } + if let Some(page) = out.page { + bs.page = page; + } + // A command run from a page may itself have opened (or replaced) the view; keep that. + if out.close { + app.backstage = None; + } else if app.backstage.is_none() { + app.backstage = Some(bs); + } +} + +/// Save: saves a workbook that has a file and returns to the sheet; a new workbook goes to the +/// Save As page first, like Excel's. +fn save(app: &mut SheetApp, out: &mut Outcome) { + if !app.commit_edit(0, 0, false, false) { + return; + } + if app.session.active().is_some_and(|d| d.path.is_some()) { + app.run_or_alert("file.save", json!({})); + out.close = true; + } else { + out.page = Some(Page::SaveAs); + } +} + +// ---------------------------------------------------------------- navigation + +fn nav_item(ui: &mut Ui, icon: Icon, label: &str, selected: bool, text: Color32, sel: Color32) -> egui::Response { + let (rect, resp) = ui.allocate_exact_size(vec2(NAV_W, 38.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, label)); + let p = ui.painter(); + if selected { + p.rect_filled(rect, 0.0, sel); + p.rect_filled(Rect::from_min_size(rect.min, vec2(4.0, rect.height())), 0.0, text); + } else if resp.hovered() { + p.rect_filled(rect, 0.0, sel.gamma_multiply(0.55)); + } + icons::paint(p, Rect::from_center_size(pos2(rect.left() + 30.0, rect.center().y), vec2(17.0, 17.0)), icon, text); + p.text(pos2(rect.left() + 50.0, rect.center().y), Align2::LEFT_CENTER, label, theme::ui_font(14.0), text); + resp +} + +fn nav_separator(ui: &mut Ui, text: Color32) { + let (rect, _) = ui.allocate_exact_size(vec2(NAV_W, 17.0), Sense::hover()); + let y = rect.center().y; + ui.painter().line_segment([pos2(rect.left() + 20.0, y), pos2(rect.right() - 20.0, y)], Stroke::new(1.0, text.gamma_multiply(0.35))); +} + +// ---------------------------------------------------------------- shared pieces + +fn section(ui: &mut Ui, lang: Language, title: &str) { + let t = Tokens::get(ui.ctx()); + ui.add_space(6.0); + ui.label(RichText::new(lang.tr(title)).font(theme::ui_bold(15.0)).color(t.text)); + ui.add_space(8.0); +} + +fn dim(ui: &mut Ui, text: &str) { + let t = Tokens::get(ui.ctx()); + ui.label(RichText::new(text).font(theme::ui_font(12.5)).color(t.text_dim)); +} + +/// A new-workbook tile: a small sheet drawn in code over its name. +fn tile(ui: &mut Ui, label: &str, sample: Option) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(150.0, 140.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + let p = ui.painter(); + let thumb = Rect::from_min_size(rect.min, vec2(rect.width(), 104.0)); + p.rect_filled(thumb, 4.0, t.grid_bg); + let edge = if resp.hovered() { Stroke::new(2.0, t.accent) } else { Stroke::new(1.0, t.ribbon_border) }; + let inner = thumb.shrink(10.0); + let (rows, cols) = (7, 4); + let (rh, cw) = (inner.height() / rows as f32, inner.width() / cols as f32); + if let Some(i) = sample { + // A header band and a few "values" so each sample reads differently from a blank book. + p.rect_filled(Rect::from_min_size(inner.min, vec2(inner.width(), rh)), 0.0, t.accent_soft); + for r in 1..rows { + let len = ((r * (i + 2) * 7 + i * 3) % 10) as f32 / 10.0; + let bar = Rect::from_min_size( + pos2(inner.left() + cw + 3.0, inner.top() + r as f32 * rh + 3.0), + vec2((cw * 3.0 - 6.0) * (0.25 + 0.75 * len), rh - 6.0), + ); + p.rect_filled(bar, 1.0, t.accent.gamma_multiply(0.75)); + } + } + for r in 0..=rows { + let y = inner.top() + r as f32 * rh; + p.line_segment([pos2(inner.left(), y), pos2(inner.right(), y)], Stroke::new(1.0, t.gridline)); + } + for c in 0..=cols { + let x = inner.left() + c as f32 * cw; + p.line_segment([pos2(x, inner.top()), pos2(x, inner.bottom())], Stroke::new(1.0, t.gridline)); + } + p.rect_stroke(thumb, 4.0, edge, StrokeKind::Inside); + let galley = p.layout(label.to_string(), theme::ui_font(12.5), t.text, rect.width()); + p.galley(pos2(rect.left(), thumb.bottom() + 6.0), galley, t.text); + resp +} + +/// Blank workbook and the built-in samples. +fn new_tiles(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) { + let lang = app.ui.language; + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(18.0, 14.0); + if tile(ui, lang.tr("Blank workbook"), None).clicked() { + app.run_or_alert("file.new", json!({})); + out.close = true; + } + for (i, (name, title)) in gridcraft_engine::sample::SAMPLES.iter().enumerate() { + if tile(ui, title, Some(i)).clicked() { + app.run_or_alert("file.new", json!({"sample": name})); + out.close = true; + } + } + }); +} + +/// File name and folder of a recent path, split as text (nothing on disk is read). +fn split_path(path: &str) -> (String, String) { + let p = std::path::Path::new(path); + let name = p.file_name().and_then(|n| n.to_str()).unwrap_or(path).to_string(); + let folder = p.parent().and_then(|f| f.to_str()).unwrap_or("").to_string(); + (name, folder) +} + +/// Recent workbooks, newest first. Opening one is the user's explicit choice. +fn recent_list(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome, limit: usize) { + let t = Tokens::get(ui.ctx()); + let lang = app.ui.language; + if app.ui.recent.is_empty() { + dim(ui, lang.tr("No recent workbooks yet.")); + return; + } + let width = ui.available_width().min(CONTENT_W); + for path in app.ui.recent.iter().take(limit).cloned().collect::>() { + let (name, folder) = split_path(&path); + let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name)); + let p = ui.painter(); + if resp.hovered() { + p.rect_filled(rect, 4.0, t.hover); + } + icons::paint(p, Rect::from_center_size(pos2(rect.left() + 20.0, rect.center().y), vec2(20.0, 20.0)), Icon::Book, t.text); + p.text(pos2(rect.left() + 42.0, rect.center().y - 8.0), Align2::LEFT_CENTER, &name, theme::ui_font(13.5), t.text); + p.text(pos2(rect.left() + 42.0, rect.center().y + 9.0), Align2::LEFT_CENTER, &folder, theme::ui_font(11.5), t.text_dim); + let resp = resp.on_hover_text(&path); + if resp.clicked() { + app.open_path(&path); + if app.session.active().and_then(|d| d.path.as_deref()) == Some(path.as_str()) { + out.close = true; + } + } + } +} + +/// A large clickable row (icon, title, one-line description). +fn action_row(ui: &mut Ui, icon: Icon, title: &str, detail: &str) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let width = ui.available_width().min(CONTENT_W); + let (rect, resp) = ui.allocate_exact_size(vec2(width, 56.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, title)); + let p = ui.painter(); + if resp.hovered() { + p.rect_filled(rect, 4.0, t.hover); + } + icons::paint(p, Rect::from_center_size(pos2(rect.left() + 24.0, rect.center().y), vec2(24.0, 24.0)), icon, t.text); + p.text(pos2(rect.left() + 52.0, rect.center().y - 9.0), Align2::LEFT_CENTER, title, theme::ui_bold(13.5), t.text); + p.text(pos2(rect.left() + 52.0, rect.center().y + 10.0), Align2::LEFT_CENTER, detail, theme::ui_font(12.0), t.text_dim); + resp +} + +/// A bordered square command button (Browse…, Print, Protect Workbook). +fn square_button(ui: &mut Ui, icon: Icon, label: &str) -> egui::Response { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(96.0, 88.0), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + let p = ui.painter(); + p.rect_filled(rect, 6.0, if resp.hovered() { t.hover } else { t.ribbon }); + p.rect_stroke(rect, 6.0, Stroke::new(1.0, if resp.hovered() { t.accent } else { t.ribbon_border }), StrokeKind::Inside); + icons::paint(p, Rect::from_center_size(pos2(rect.center().x, rect.top() + 32.0), vec2(30.0, 30.0)), icon, t.text); + let galley = p.layout(label.to_string(), theme::ui_font(12.0), t.text, rect.width() - 8.0); + p.galley(pos2(rect.center().x - galley.size().x / 2.0, rect.top() + 56.0), galley, t.text); + resp +} + +// ---------------------------------------------------------------- pages + +fn home(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) { + let lang = app.ui.language; + section(ui, lang, "New"); + new_tiles(app, ui, out); + ui.add_space(22.0); + section(ui, lang, "Recent"); + recent_list(app, ui, out, 8); + if app.ui.recent.len() > 8 { + ui.add_space(6.0); + if ui.link(lang.tr("More workbooks")).clicked() { + out.page = Some(Page::Open); + } + } +} + +fn new_page(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) { + new_tiles(app, ui, out); +} + +fn open_page(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) { + let lang = app.ui.language; + if square_button(ui, Icon::OpenFolder, lang.tr("Browse…")).clicked() { + let before = app.session.active().map(|d| d.uid); + app.open_dialog("open", json!({})); + // Desktop pickers return at once; the web picker delivers later (see `SheetApp::logic`). + if app.session.active().map(|d| d.uid) != before { + out.close = true; + } + } + ui.add_space(22.0); + section(ui, lang, "Recent"); + recent_list(app, ui, out, usize::MAX); +} + +fn info(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage) { + let t = Tokens::get(ui.ctx()); + let lang = app.ui.language; + let Some(d) = app.session.active() else { + dim(ui, lang.tr("No workbook is open.")); + return; + }; + let title = d.display_title(); + let path = d.path.clone(); + let sheets = d.wb.sheets.len(); + let protected = d.wb.protected_structure; + let current: Vec = { + let pr = &d.wb.props; + vec![pr.title.clone(), pr.subject.clone(), pr.author.clone(), pr.company.clone(), pr.keywords.clone(), pr.description.clone()] + }; + ui.horizontal(|ui| { + icons::paint(ui.painter(), Rect::from_min_size(ui.cursor().min, vec2(28.0, 28.0)), Icon::Book, t.text); + ui.add_space(38.0); + ui.vertical(|ui| { + ui.label(RichText::new(&title).font(theme::ui_bold(16.0)).color(t.text)); + match &path { + Some(p) => dim(ui, p), + None => dim(ui, lang.tr("Not saved yet")), + } + }); + }); + ui.add_space(18.0); + ui.horizontal(|ui| { + if square_button(ui, Icon::Lock, lang.tr("Protect Workbook")).clicked() { + app.run_or_alert("review.protectWorkbook", json!({"on": !protected})); + } + ui.add_space(12.0); + ui.vertical(|ui| { + ui.add_space(14.0); + ui.label(RichText::new(lang.tr("Protect Workbook")).font(theme::ui_bold(13.5)).color(t.text)); + let text = if protected { + "Sheet structure is locked: sheets can't be added, moved, renamed or deleted." + } else { + "Lock the sheet structure so sheets can't be added, moved, renamed or deleted." + }; + dim(ui, lang.tr(text)); + }); + }); + ui.add_space(22.0); + section(ui, lang, "Properties"); + egui::Grid::new("backstage_props").num_columns(2).spacing(vec2(18.0, 8.0)).show(ui, |ui| { + ui.label(RichText::new(lang.tr("Location")).color(t.text_dim)); + ui.label(path.as_deref().unwrap_or(lang.tr("Not saved yet"))); + ui.end_row(); + ui.label(RichText::new(lang.tr("Sheets")).color(t.text_dim)); + ui.label(sheets.to_string()); + ui.end_row(); + for (i, (key, label)) in PROPERTIES.iter().enumerate() { + let (Some(draft), Some(saved)) = (bs.props.get_mut(i), current.get(i)) else { continue }; + ui.label(RichText::new(lang.tr(label)).color(t.text_dim)); + let id = ui.make_persistent_id(("backstage_prop", *key)); + // Show the workbook's value unless the field is being edited (undo, another window). + if !ui.ctx().memory(|m| m.has_focus(id)) { + draft.clone_from(saved); + } + let resp = ui.add(egui::TextEdit::singleline(draft).id(id).desired_width(360.0)); + if resp.lost_focus() && draft != saved { + let mut params = serde_json::Map::new(); + params.insert((*key).to_string(), json!(draft.clone())); + app.run_or_alert("file.properties", Json::Object(params)); + } + ui.end_row(); + } + }); +} + +fn save_as(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) { + let lang = app.ui.language; + dim(ui, lang.tr("Save the workbook under another name or in another folder.")); + ui.add_space(14.0); + // Desktop: the system Save dialog. Web: the workbook downloads as .xlsx. + let label = if app.services.pick_save.is_some() || app.services.download.is_none() { "Browse…" } else { "Download (.xlsx)" }; + if square_button(ui, Icon::OpenFolder, lang.tr(label)).clicked() && app.commit_edit(0, 0, false, false) { + let before = app.session.active().map(|d| (d.path.clone(), d.is_dirty())); + app.open_dialog("saveAs", json!({})); + let after = app.session.active().map(|d| (d.path.clone(), d.is_dirty())); + if after != before || app.services.pick_save.is_none() { + out.close = true; + } + } + ui.add_space(18.0); + dim(ui, lang.tr("PDF, CSV and web pages are on the Export page.")); +} + +fn print(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) { + let t = Tokens::get(ui.ctx()); + let lang = app.ui.language; + let Some((uid, revision)) = app.session.active().map(|d| (d.uid, d.revision)) else { + dim(ui, lang.tr("No workbook is open.")); + return; + }; + let mut params = json!({"sheets": if bs.print_all { "all" } else { "active" }}); + if bs.fit_to_page { + params["fitToPage"] = json!(true); + } + let key = (uid, revision, bs.print_all, bs.fit_to_page); + if bs.preview.as_ref().is_none_or(|(k, _)| *k != key) { + let preview = app.session.run("file.printPreview", params.clone()).unwrap_or(Json::Null); + bs.preview = Some((key, preview)); + } + let pages: Vec = bs.preview.as_ref().and_then(|(_, p)| p.get("pageList")).and_then(Json::as_array).cloned().unwrap_or_default(); + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + ui.set_width(250.0); + if square_button(ui, Icon::Print, lang.tr("Print")).clicked() && app.commit_edit(0, 0, false, false) { + app.run_or_alert("file.print", params.clone()); + out.close = true; + } + ui.add_space(16.0); + let scope = if bs.print_all { "Print Entire Workbook" } else { "Print Active Sheets" }; + egui::ComboBox::from_id_salt("backstage_print_scope").width(230.0).selected_text(lang.tr(scope)).show_ui(ui, |ui| { + for (all, label) in [(false, "Print Active Sheets"), (true, "Print Entire Workbook")] { + if ui.selectable_label(bs.print_all == all, lang.tr(label)).clicked() { + bs.print_all = all; + bs.preview_page = 0; + } + } + }); + ui.add_space(8.0); + if ui.checkbox(&mut bs.fit_to_page, lang.tr("Fit Sheet on One Page")).changed() { + bs.preview_page = 0; + } + ui.add_space(8.0); + if ui.link(lang.tr("Page Setup…")).clicked() { + app.open_dialog("pageSetup", json!({})); + } + }); + ui.add_space(24.0); + ui.vertical(|ui| { + if pages.is_empty() { + dim(ui, lang.tr("Nothing to print.")); + return; + } + let last = pages.len().saturating_sub(1); + bs.preview_page = bs.preview_page.min(last); + let Some(page) = pages.get(bs.preview_page) else { return }; + // Paper proportions from the preview (points); hostile sizes fall back to Letter. + let w = page.get("width").and_then(Json::as_f64).filter(|w| w.is_finite() && *w > 1.0).unwrap_or(612.0) as f32; + let h = page.get("height").and_then(Json::as_f64).filter(|h| h.is_finite() && *h > 1.0).unwrap_or(792.0) as f32; + let scale = (520.0 / h).min(440.0 / w); + let (rect, _) = ui.allocate_exact_size(vec2(w * scale, h * scale), Sense::hover()); + let p = ui.painter(); + p.rect_filled(rect.translate(vec2(2.0, 3.0)), 2.0, t.shadow); + p.rect_filled(rect, 2.0, t.grid_bg); + p.rect_stroke(rect, 2.0, Stroke::new(1.0, t.ribbon_border), StrokeKind::Inside); + if let Some(d) = app.session.active() { + paint_page(p, rect, w, page, &d.wb, &t); + } + let sheet = page.get("sheet").and_then(Json::as_str).unwrap_or(""); + let range = page.get("range").and_then(Json::as_str).unwrap_or(""); + ui.add_space(4.0); + dim(ui, &format!("{sheet} · {range}")); + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.add_enabled(bs.preview_page > 0, egui::Button::new("◀")).on_hover_text(lang.tr("Previous page")).clicked() { + bs.preview_page = bs.preview_page.saturating_sub(1); + } + let text = lang + .tr("Page {page} of {pages}") + .replace("{page}", &(bs.preview_page + 1).to_string()) + .replace("{pages}", &pages.len().to_string()); + ui.label(text); + if ui.add_enabled(bs.preview_page < last, egui::Button::new("▶")).on_hover_text(lang.tr("Next page")).clicked() { + bs.preview_page = (bs.preview_page + 1).min(last); + } + }); + }); + }); +} + +/// Most rows and columns one preview page draws (a page holds far fewer at any readable scale). +const PREVIEW_ROWS: u32 = 200; +const PREVIEW_COLS: u32 = 60; + +/// Draws one printed page in miniature: the cells' displayed text at the page's print scale, +/// inside the sheet's margins. `paper_w` is the page width in points. +fn paint_page(p: &egui::Painter, rect: Rect, paper_w: f32, page: &Json, wb: &gridcraft_engine::model::Workbook, t: &Tokens) { + let Some(range) = page.get("range").and_then(Json::as_str).and_then(gridcraft_engine::core::RangeRef::parse) else { return }; + let Some(sh) = page.get("sheet").and_then(Json::as_str).and_then(|n| wb.sheet_index(n)).and_then(|i| wb.sheets.get(i)) else { return }; + // Preview pixels per point, and per sheet pixel (sheet geometry is 96 dpi: 0.75 pt per pixel). + let per_pt = rect.width() / paper_w; + let print_scale = + page.get("scale").and_then(Json::as_f64).filter(|s| s.is_finite() && *s > 0.0).map_or(1.0, |s| (s / 100.0) as f32).clamp(0.1, 4.0); + let per_px = per_pt * 0.75 * print_scale; + let margin = + |i: usize, default: f32| sh.print.margins.get(i).copied().filter(|m| m.is_finite()).unwrap_or(default).clamp(0.0, 3.0) * 72.0 * per_pt; + let body = Rect::from_min_max( + pos2(rect.left() + margin(0, 0.7), rect.top() + margin(2, 0.75)), + pos2(rect.right() - margin(1, 0.7), rect.bottom() - margin(3, 0.75)), + ); + if !body.is_positive() { + return; + } + let font = theme::ui_font((11.0 * per_pt * print_scale).clamp(3.0, 14.0)); + let last_row = range.end.row.min(range.start.row.saturating_add(PREVIEW_ROWS)); + let last_col = range.end.col.min(range.start.col.saturating_add(PREVIEW_COLS)); + let mut y = body.top(); + for row in range.start.row..=last_row { + let h = sh.row_height(row) * per_px; + if h <= 0.0 { + continue; + } + if y >= body.bottom() { + break; + } + let mut x = body.left(); + for col in range.start.col..=last_col { + let w = sh.col_width(col) * per_px; + if w <= 0.0 { + continue; + } + if x >= body.right() { + break; + } + let cell = gridcraft_engine::core::CellRef::new(row, col); + let text = gridcraft_engine::display::cell_text(wb, sh, cell); + if !text.is_empty() { + let numeric = sh.value(cell).as_f64().is_some(); + // Text runs on over empty neighbours, as on the sheet; numbers stay in their cell. + let mut span = w; + if !numeric { + let mut next = col.saturating_add(1); + while next <= last_col && x + span < body.right() && sh.value(gridcraft_engine::core::CellRef::new(row, next)).is_empty() { + span += sh.col_width(next) * per_px; + next = next.saturating_add(1); + } + } + let clip = Rect::from_min_size(pos2(x, y), vec2(span, h)).intersect(body); + let (anchor, at) = + if numeric { (Align2::RIGHT_CENTER, pos2(x + w - 1.0, y + h / 2.0)) } else { (Align2::LEFT_CENTER, pos2(x + 1.0, y + h / 2.0)) }; + p.with_clip_rect(clip).text(at, anchor, text, font.clone(), t.cell_text); + } + x += w; + } + y += h; + } +} + +fn export(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) { + let lang = app.ui.language; + if action_row(ui, Icon::Paper, lang.tr("Create PDF"), lang.tr("Save the sheet as a PDF document that keeps its page layout.")).clicked() + && export_as(app, "pdf") + { + out.close = true; + } + ui.add_space(14.0); + section(ui, lang, "Change File Type"); + for (fmt, icon, title, detail) in [ + ("csv", Icon::TextColumns, "CSV (Comma delimited)", "Save the active sheet as comma-separated text."), + ("html", Icon::Link, "Web Page (.html)", "Save the workbook as a web page."), + ] { + if action_row(ui, icon, lang.tr(title), lang.tr(detail)).clicked() && export_as(app, fmt) { + out.close = true; + } + } +} + +/// Exports the active workbook as `pdf`, `csv` or `html`: through the save picker on the desktop, +/// as a browser download on the web. Returns whether a file was written or handed over. +pub fn export_as(app: &mut SheetApp, fmt: &str) -> bool { + if !app.commit_edit(0, 0, false, false) { + return false; + } + let id = match fmt { + "pdf" => "file.exportPdf", + "csv" => "file.exportCsv", + "html" => "file.exportHtml", + _ => return false, + }; + let title = app.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into()); + let stem = std::path::Path::new(&title).file_stem().and_then(|s| s.to_str()).unwrap_or("Book1").to_string(); + if let Some(pick) = &app.services.pick_save { + let Some(path) = pick(&format!("{stem}.{fmt}")) else { return false }; + // The picker may suggest a workbook extension; the export writes the chosen format. + let path = std::path::Path::new(&path).with_extension(fmt).to_string_lossy().into_owned(); + return match app.run(id, json!({"path": path})) { + Ok(_) => true, + Err(e) => { + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + false + } + }; + } + if app.services.download.is_none() { + app.message = Some(("GridCraft".into(), "Exporting needs a file picker, which this window doesn't have.".into())); + return false; + } + if fmt == "pdf" { + // Without a path the PDF comes back as bytes, which `SheetApp::run` hands to the browser. + return match app.run(id, json!({})) { + Ok(_) => true, + Err(e) => { + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + false + } + }; + } + match app.session.run("file.saveBytes", json!({"format": fmt})) { + Ok(r) => { + let bytes = r.get("base64").and_then(Json::as_str).and_then(gridcraft_engine::io::base64_decode); + let name = r.get("name").and_then(Json::as_str).map_or_else(|| format!("{stem}.{fmt}"), str::to_string); + match (bytes, &app.services.download) { + (Some(b), Some(dl)) => { + dl(&name, &b); + true + } + _ => false, + } + } + Err(e) => { + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + false + } + } +} + +fn options(app: &mut SheetApp, ui: &mut Ui) { + let lang = app.ui.language; + section(ui, lang, "Options|General"); + egui::Grid::new("backstage_options_general").num_columns(2).spacing(vec2(18.0, 10.0)).show(ui, |ui| { + ui.label(lang.tr("Display theme")); + let mode = app.ui.theme_mode(); + let current = match mode { + "system" => "System", + "dark" => "Dark", + _ => "Light", + }; + egui::ComboBox::from_id_salt("backstage_theme").width(160.0).selected_text(lang.tr(current)).show_ui(ui, |ui| { + for (value, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] { + if ui.selectable_label(mode == value, lang.tr(label)).clicked() { + app.run_or_alert("view.theme", json!({"mode": value})); + } + } + }); + ui.end_row(); + ui.label(lang.tr("Interface language")); + egui::ComboBox::from_id_salt("backstage_language").width(160.0).selected_text(lang.name()).show_ui(ui, |ui| { + for l in i18n::Language::ALL { + if ui.selectable_label(l == lang, l.name()).clicked() { + app.run_or_alert("app.language.set", json!({"language": l.code()})); + } + } + }); + ui.end_row(); + }); + ui.add_space(6.0); + let mut formula_bar = app.ui.formula_bar; + if ui.checkbox(&mut formula_bar, lang.tr("Show formula bar")).changed() { + app.run_or_alert("view.formulaBar", json!({"on": formula_bar})); + } + ui.add_space(18.0); + section(ui, lang, "Options|Save"); + let mut autosave = app.grid.autosave(); + if ui.checkbox(&mut autosave, lang.tr("Save .xlsx workbooks automatically after every change")).changed() { + app.grid.toggle_autosave(); + } + let Some(calc) = app.session.active().map(|d| d.wb.calc.clone()) else { return }; + ui.add_space(18.0); + section(ui, lang, "Formulas"); + ui.label(lang.tr("Workbook calculation")); + use gridcraft_engine::model::CalcMode; + for (mode, id, label) in [ + (CalcMode::Automatic, "automatic", "Automatic"), + (CalcMode::AutomaticExceptTables, "automaticExceptTables", "Automatic Except for Data Tables"), + (CalcMode::Manual, "manual", "Manual"), + ] { + if ui.radio(calc.mode == mode, lang.tr(label)).clicked() && calc.mode != mode { + app.run_or_alert("formulas.calculationOptions", json!({"mode": id})); + } + } + ui.add_space(4.0); + let mut iterative = calc.iterative; + if ui.checkbox(&mut iterative, lang.tr("Enable iterative calculation")).changed() { + app.run_or_alert("formulas.calculationOptions", json!({"iterative": iterative})); + } + ui.add_space(6.0); + dim(ui, lang.tr("Calculation settings are saved with the workbook.")); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn page_ids_round_trip_and_unknown_ids_are_refused() { + for page in Page::ALL { + assert_eq!(Page::parse(page.id()), Some(page)); + } + assert_eq!(Page::parse(" SAVEAS "), Some(Page::SaveAs)); + assert_eq!(Page::parse("account"), None); + assert_eq!(Page::parse(""), None); + } + + #[test] + fn recent_paths_split_without_touching_the_disk() { + assert_eq!(split_path("/home/me/Budget.xlsx"), ("Budget.xlsx".into(), "/home/me".into())); + assert_eq!(split_path("Budget.xlsx"), ("Budget.xlsx".into(), "".into())); + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index e6da0f00b..38bfcce6e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -73,6 +73,7 @@ pub fn inspect(app: &SheetApp, ctx: &egui::Context) -> Json { "scroll": [v.scroll.x, v.scroll.y], "editor": app.editor.as_ref().map(|e| json!({"cell": e.cell.a1(), "text": e.text, "formulaBar": e.from_formula_bar, "point": e.can_point()})), "dialog": app.dialog.as_ref().map(|d| json!({"name": d.name, "title": d.title, "values": d.values, "tab": d.tab})), + "backstage": app.backstage.as_ref().map(|b| b.page.id()), "message": app.message.as_ref().map(|(t, m)| json!({"title": t, "text": m})), "selectedChart": app.selected_chart, "mode": format!("{:?}", app.session.mode), @@ -174,6 +175,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - ("view.theme", "Display Theme", ""), ("view.zoom100", "100%", ""), ("ui.dialog", "Open Dialog", ""), + ("ui.backstage", "File (Backstage view)", "{page?: home|new|open|info|saveAs|print|export|options|close}"), ("app.language.set", "Interface Language", "{language: \"en\"|\"zh\"|\"ja\"|\"ko\"|\"ru\"|\"pt\"} (alias: code)"), ("app.language.english", "Interface Language: English", ""), ("app.language.japanese", "Interface Language: Japanese", ""), diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 1e7b21245..420911a33 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -416,6 +416,67 @@ const JAPANESE: &[(&str, &str)] = &[ ("More\nFunctions", "その他の関数"), ("Sheet Options|View", "画面"), ("Print", "印刷"), + // File › Backstage view. + ("File", "ファイル"), + ("Back", "戻る"), + ("New", "新規"), + ("Open", "開く"), + ("Info", "情報"), + ("Save", "上書き保存"), + ("Save As", "名前を付けて保存"), + ("Export", "エクスポート"), + ("Close", "閉じる"), + ("Options", "オプション"), + ("Blank workbook", "空白のブック"), + ("Recent", "最近使ったアイテム"), + ("No recent workbooks yet.", "最近使ったブックはまだありません。"), + ("More workbooks", "その他のブック"), + ("Browse…", "参照…"), + ("Not saved yet", "まだ保存されていません"), + ("Location", "場所"), + ("Sheets", "シート"), + ("Protect Workbook", "ブックの保護"), + ( + "Sheet structure is locked: sheets can't be added, moved, renamed or deleted.", + "シートの構成はロックされています。シートの追加、移動、名前の変更、削除はできません。", + ), + ( + "Lock the sheet structure so sheets can't be added, moved, renamed or deleted.", + "シートの構成をロックして、シートの追加、移動、名前の変更、削除をできないようにします。", + ), + ("Properties", "プロパティ"), + ("Title", "タイトル"), + ("Subject", "件名"), + ("Author", "作成者"), + ("Company", "会社名"), + ("Keywords", "キーワード"), + ("Description", "説明"), + ("No workbook is open.", "ブックが開かれていません。"), + ("Save the workbook under another name or in another folder.", "ブックを別の名前または別のフォルダーに保存します。"), + ("Download (.xlsx)", "ダウンロード (.xlsx)"), + ("PDF, CSV and web pages are on the Export page.", "PDF、CSV、Web ページは [エクスポート] ページにあります。"), + ("Print Active Sheets", "作業中のシートを印刷"), + ("Print Entire Workbook", "ブック全体を印刷"), + ("Fit Sheet on One Page", "シートを 1 ページに印刷"), + ("Page Setup…", "ページ設定…"), + ("Page {page} of {pages}", "{page} / {pages} ページ"), + ("Nothing to print.", "印刷する内容がありません。"), + ("Previous page", "前のページ"), + ("Next page", "次のページ"), + ("Create PDF", "PDF の作成"), + ("Save the sheet as a PDF document that keeps its page layout.", "ページ レイアウトを保ったまま、シートを PDF ドキュメントとして保存します。"), + ("Change File Type", "ファイルの種類の変更"), + ("CSV (Comma delimited)", "CSV (コンマ区切り)"), + ("Save the active sheet as comma-separated text.", "作業中のシートをコンマ区切りのテキストとして保存します。"), + ("Web Page (.html)", "Web ページ (.html)"), + ("Save the workbook as a web page.", "ブックを Web ページとして保存します。"), + ("Options|General", "全般"), + ("Options|Save", "保存"), + ("Show formula bar", "数式バーを表示する"), + ("Save .xlsx workbooks automatically after every change", "変更のたびに .xlsx ブックを自動的に保存する"), + ("Workbook calculation", "ブックの計算"), + ("Enable iterative calculation", "反復計算を行う"), + ("Calculation settings are saved with the workbook.", "計算の設定はブックと共に保存されます。"), ]; #[cfg(test)] diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index c37ae3cab..c0d7aa751 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -268,6 +268,65 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "어둡게", "Sheet Options|View" => "화면", "Print" => "인쇄", + // File › Backstage view. + "File" => "파일", + "Back" => "뒤로", + "New" => "새로 만들기", + "Open" => "열기", + "Info" => "정보", + "Save" => "저장", + "Save As" => "다른 이름으로 저장", + "Export" => "내보내기", + "Close" => "닫기", + "Options" => "옵션", + "Blank workbook" => "새 통합 문서", + "Recent" => "최근 항목", + "No recent workbooks yet." => "최근 통합 문서가 아직 없습니다.", + "More workbooks" => "더 많은 통합 문서", + "Browse…" => "찾아보기…", + "Not saved yet" => "아직 저장되지 않음", + "Location" => "위치", + "Sheets" => "시트", + "Protect Workbook" => "통합 문서 보호", + "Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => { + "시트 구조가 잠겨 있습니다. 시트를 추가, 이동, 이름 바꾸기 또는 삭제할 수 없습니다." + } + "Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => { + "시트 구조를 잠가 시트를 추가, 이동, 이름 바꾸기 또는 삭제할 수 없게 합니다." + } + "Properties" => "속성", + "Title" => "제목", + "Subject" => "주제", + "Author" => "만든 이", + "Company" => "회사", + "Keywords" => "키워드", + "Description" => "설명", + "No workbook is open." => "열려 있는 통합 문서가 없습니다.", + "Save the workbook under another name or in another folder." => "통합 문서를 다른 이름 또는 다른 폴더에 저장합니다.", + "Download (.xlsx)" => "다운로드(.xlsx)", + "PDF, CSV and web pages are on the Export page." => "PDF, CSV 및 웹 페이지는 내보내기 페이지에 있습니다.", + "Print Active Sheets" => "활성 시트 인쇄", + "Print Entire Workbook" => "전체 통합 문서 인쇄", + "Fit Sheet on One Page" => "한 페이지에 시트 맞추기", + "Page Setup…" => "페이지 설정…", + "Page {page} of {pages}" => "{page}/{pages} 페이지", + "Nothing to print." => "인쇄할 내용이 없습니다.", + "Previous page" => "이전 페이지", + "Next page" => "다음 페이지", + "Create PDF" => "PDF 만들기", + "Save the sheet as a PDF document that keeps its page layout." => "페이지 레이아웃을 유지한 채 시트를 PDF 문서로 저장합니다.", + "Change File Type" => "파일 형식 변경", + "CSV (Comma delimited)" => "CSV(쉼표로 분리)", + "Save the active sheet as comma-separated text." => "활성 시트를 쉼표로 구분된 텍스트로 저장합니다.", + "Web Page (.html)" => "웹 페이지(.html)", + "Save the workbook as a web page." => "통합 문서를 웹 페이지로 저장합니다.", + "Options|General" => "일반", + "Options|Save" => "저장", + "Show formula bar" => "수식 입력줄 표시", + "Save .xlsx workbooks automatically after every change" => "변경할 때마다 .xlsx 통합 문서를 자동으로 저장", + "Workbook calculation" => "통합 문서 계산", + "Enable iterative calculation" => "반복 계산 사용", + "Calculation settings are saved with the workbook." => "계산 설정은 통합 문서와 함께 저장됩니다.", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index 1bdc2c3f2..a5d00a072 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -269,6 +269,65 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "Escuro", "Sheet Options|View" => "Exibir", "Print" => "Imprimir", + // File › Backstage view. + "File" => "Arquivo", + "Back" => "Voltar", + "New" => "Novo", + "Open" => "Abrir", + "Info" => "Informações", + "Save" => "Salvar", + "Save As" => "Salvar como", + "Export" => "Exportar", + "Close" => "Fechar", + "Options" => "Opções", + "Blank workbook" => "Pasta de trabalho em branco", + "Recent" => "Recentes", + "No recent workbooks yet." => "Ainda não há pastas de trabalho recentes.", + "More workbooks" => "Mais pastas de trabalho", + "Browse…" => "Procurar…", + "Not saved yet" => "Ainda não salvo", + "Location" => "Local", + "Sheets" => "Planilhas", + "Protect Workbook" => "Proteger pasta de trabalho", + "Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => { + "A estrutura das planilhas está bloqueada: não é possível adicionar, mover, renomear nem excluir planilhas." + } + "Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => { + "Bloqueie a estrutura das planilhas para que não possam ser adicionadas, movidas, renomeadas nem excluídas." + } + "Properties" => "Propriedades", + "Title" => "Título", + "Subject" => "Assunto", + "Author" => "Autor", + "Company" => "Empresa", + "Keywords" => "Palavras-chave", + "Description" => "Descrição", + "No workbook is open." => "Nenhuma pasta de trabalho está aberta.", + "Save the workbook under another name or in another folder." => "Salve a pasta de trabalho com outro nome ou em outra pasta.", + "Download (.xlsx)" => "Baixar (.xlsx)", + "PDF, CSV and web pages are on the Export page." => "PDF, CSV e páginas da Web estão na página Exportar.", + "Print Active Sheets" => "Imprimir planilhas ativas", + "Print Entire Workbook" => "Imprimir toda a pasta de trabalho", + "Fit Sheet on One Page" => "Ajustar planilha em uma página", + "Page Setup…" => "Configurar página…", + "Page {page} of {pages}" => "Página {page} de {pages}", + "Nothing to print." => "Nada para imprimir.", + "Previous page" => "Página anterior", + "Next page" => "Próxima página", + "Create PDF" => "Criar PDF", + "Save the sheet as a PDF document that keeps its page layout." => "Salve a planilha como um documento PDF que mantém o layout da página.", + "Change File Type" => "Alterar tipo de arquivo", + "CSV (Comma delimited)" => "CSV (separado por vírgulas)", + "Save the active sheet as comma-separated text." => "Salve a planilha ativa como texto separado por vírgulas.", + "Web Page (.html)" => "Página da Web (.html)", + "Save the workbook as a web page." => "Salve a pasta de trabalho como uma página da Web.", + "Options|General" => "Geral", + "Options|Save" => "Salvar", + "Show formula bar" => "Mostrar barra de fórmulas", + "Save .xlsx workbooks automatically after every change" => "Salvar pastas de trabalho .xlsx automaticamente após cada alteração", + "Workbook calculation" => "Cálculo da pasta de trabalho", + "Enable iterative calculation" => "Habilitar cálculo iterativo", + "Calculation settings are saved with the workbook." => "As configurações de cálculo são salvas com a pasta de trabalho.", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 8d5485658..cbe2aa3df 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -268,6 +268,65 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "Тёмная", "Sheet Options|View" => "На экране", "Print" => "Печать", + // File › Backstage view. + "File" => "Файл", + "Back" => "Назад", + "New" => "Создать", + "Open" => "Открыть", + "Info" => "Сведения", + "Save" => "Сохранить", + "Save As" => "Сохранить как", + "Export" => "Экспорт", + "Close" => "Закрыть", + "Options" => "Параметры", + "Blank workbook" => "Пустая книга", + "Recent" => "Последние", + "No recent workbooks yet." => "Недавних книг пока нет.", + "More workbooks" => "Другие книги", + "Browse…" => "Обзор…", + "Not saved yet" => "Ещё не сохранено", + "Location" => "Расположение", + "Sheets" => "Листы", + "Protect Workbook" => "Защитить книгу", + "Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => { + "Структура листов заблокирована: листы нельзя добавлять, перемещать, переименовывать или удалять." + } + "Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => { + "Заблокировать структуру листов, чтобы их нельзя было добавлять, перемещать, переименовывать или удалять." + } + "Properties" => "Свойства", + "Title" => "Название", + "Subject" => "Тема", + "Author" => "Автор", + "Company" => "Организация", + "Keywords" => "Ключевые слова", + "Description" => "Описание", + "No workbook is open." => "Нет открытых книг.", + "Save the workbook under another name or in another folder." => "Сохранить книгу под другим именем или в другой папке.", + "Download (.xlsx)" => "Скачать (.xlsx)", + "PDF, CSV and web pages are on the Export page." => "PDF, CSV и веб-страницы — на странице «Экспорт».", + "Print Active Sheets" => "Напечатать активные листы", + "Print Entire Workbook" => "Напечатать всю книгу", + "Fit Sheet on One Page" => "Вписать лист на одну страницу", + "Page Setup…" => "Параметры страницы…", + "Page {page} of {pages}" => "Страница {page} из {pages}", + "Nothing to print." => "Нечего печатать.", + "Previous page" => "Предыдущая страница", + "Next page" => "Следующая страница", + "Create PDF" => "Создать PDF", + "Save the sheet as a PDF document that keeps its page layout." => "Сохранить лист как PDF-документ с сохранением разметки страниц.", + "Change File Type" => "Изменить тип файла", + "CSV (Comma delimited)" => "CSV (разделители — запятые)", + "Save the active sheet as comma-separated text." => "Сохранить активный лист как текст с разделителями-запятыми.", + "Web Page (.html)" => "Веб-страница (.html)", + "Save the workbook as a web page." => "Сохранить книгу как веб-страницу.", + "Options|General" => "Общие", + "Options|Save" => "Сохранение", + "Show formula bar" => "Показывать строку формул", + "Save .xlsx workbooks automatically after every change" => "Автоматически сохранять книги .xlsx после каждого изменения", + "Workbook calculation" => "Вычисления в книге", + "Enable iterative calculation" => "Включить итеративные вычисления", + "Calculation settings are saved with the workbook." => "Параметры вычислений сохраняются вместе с книгой.", _ => return None, }) } diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 9f04ada73..b1bebfd85 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -268,6 +268,61 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Dark" => "深色", "Sheet Options|View" => "屏幕", "Print" => "打印", + // File › Backstage view. + "File" => "文件", + "Back" => "返回", + "New" => "新建", + "Open" => "打开", + "Info" => "信息", + "Save" => "保存", + "Save As" => "另存为", + "Export" => "导出", + "Close" => "关闭", + "Options" => "选项", + "Blank workbook" => "空白工作簿", + "Recent" => "最近", + "No recent workbooks yet." => "还没有最近使用的工作簿。", + "More workbooks" => "更多工作簿", + "Browse…" => "浏览…", + "Not saved yet" => "尚未保存", + "Location" => "位置", + "Sheets" => "工作表", + "Protect Workbook" => "保护工作簿", + "Sheet structure is locked: sheets can't be added, moved, renamed or deleted." => "工作表结构已锁定:无法添加、移动、重命名或删除工作表。", + "Lock the sheet structure so sheets can't be added, moved, renamed or deleted." => "锁定工作表结构,使工作表无法被添加、移动、重命名或删除。", + "Properties" => "属性", + "Title" => "标题", + "Subject" => "主题", + "Author" => "作者", + "Company" => "公司", + "Keywords" => "关键词", + "Description" => "说明", + "No workbook is open." => "没有打开的工作簿。", + "Save the workbook under another name or in another folder." => "以其他名称或在其他文件夹中保存工作簿。", + "Download (.xlsx)" => "下载 (.xlsx)", + "PDF, CSV and web pages are on the Export page." => "PDF、CSV 和网页位于“导出”页。", + "Print Active Sheets" => "打印活动工作表", + "Print Entire Workbook" => "打印整个工作簿", + "Fit Sheet on One Page" => "将工作表调整为一页", + "Page Setup…" => "页面设置…", + "Page {page} of {pages}" => "第 {page} 页,共 {pages} 页", + "Nothing to print." => "没有可打印的内容。", + "Previous page" => "上一页", + "Next page" => "下一页", + "Create PDF" => "创建 PDF", + "Save the sheet as a PDF document that keeps its page layout." => "将工作表另存为保留页面布局的 PDF 文档。", + "Change File Type" => "更改文件类型", + "CSV (Comma delimited)" => "CSV (逗号分隔)", + "Save the active sheet as comma-separated text." => "将活动工作表另存为逗号分隔的文本。", + "Web Page (.html)" => "网页 (.html)", + "Save the workbook as a web page." => "将工作簿另存为网页。", + "Options|General" => "常规", + "Options|Save" => "保存", + "Show formula bar" => "显示编辑栏", + "Save .xlsx workbooks automatically after every change" => "每次更改后自动保存 .xlsx 工作簿", + "Workbook calculation" => "工作簿计算", + "Enable iterative calculation" => "启用迭代计算", + "Calculation settings are saved with the workbook." => "计算设置随工作簿一起保存。", _ => return None, }) } diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index b2079c821..fcd2d8726 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -111,6 +111,12 @@ pub enum Icon { Sheet, Book, Folder, + /// File › Backstage navigation (line art, drawn in the navigation colour). + Back, + NewFile, + OpenFolder, + Info, + Print, } struct G<'a> { @@ -605,6 +611,29 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) { Icon::Folder => { g.poly(&[(1.5, 4.0), (6.0, 4.0), (7.5, 5.5), (14.5, 5.5), (14.5, 13.0), (1.5, 13.0)], YELLOW); } + Icon::Back => { + g.circle(8.0, 8.0, 6.5, None, Some(c)); + g.line(&[(11.5, 8.0), (4.8, 8.0)], c, 1.2); + g.line(&[(7.5, 5.3), (4.8, 8.0), (7.5, 10.7)], c, 1.2); + } + Icon::NewFile => { + g.line(&[(9.5, 1.5), (3.5, 1.5), (3.5, 14.5), (12.5, 14.5), (12.5, 4.5), (9.5, 1.5), (9.5, 4.5), (12.5, 4.5)], c, 1.1); + } + Icon::OpenFolder => { + g.line(&[(13.0, 6.5), (13.0, 4.5), (7.5, 4.5), (6.0, 3.0), (1.5, 3.0), (1.5, 13.0)], c, 1.1); + g.line(&[(1.5, 13.0), (4.0, 7.0), (15.0, 7.0), (12.5, 13.0), (1.5, 13.0)], c, 1.1); + } + Icon::Info => { + g.circle(8.0, 8.0, 6.5, None, Some(c)); + g.line(&[(8.0, 7.0), (8.0, 11.8)], c, 1.3); + g.circle(8.0, 4.8, 0.8, Some(c), None); + } + Icon::Print => { + g.line(&[(4.5, 5.5), (4.5, 1.5), (11.5, 1.5), (11.5, 5.5)], c, 1.1); + g.line(&[(4.0, 11.5), (1.5, 11.5), (1.5, 5.5), (14.5, 5.5), (14.5, 11.5), (12.0, 11.5)], c, 1.1); + g.line(&[(4.5, 9.0), (4.5, 14.5), (11.5, 14.5), (11.5, 9.0), (4.5, 9.0)], c, 1.1); + g.circle(12.3, 7.6, 0.6, Some(c), None); + } } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1225eecdf..32b63fa63 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -6,6 +6,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +pub mod backstage; mod border_preview; mod cell_pictures; pub mod chartview; @@ -141,6 +142,8 @@ pub struct SheetApp { pub editor: Option, pub text_box_editor: Option, pub dialog: Option, + /// The File tab's Backstage view, when open (UI-only; replaces the ribbon, grid and tabs). + pub backstage: Option, pub message: Option<(String, String)>, pub toast: Option<(String, f64)>, pub services: Services, @@ -173,6 +176,7 @@ impl SheetApp { editor: None, text_box_editor: None, dialog: None, + backstage: None, message: None, toast: None, services, @@ -339,6 +343,22 @@ impl SheetApp { self.ui.language = i18n::Language::Ja; Ok(json!({"language": i18n::Language::Ja})) } + "ui.backstage" => { + // `{"page": "info"}` opens File › Info; `{"page": "close"}` returns to the sheet. + match p.get("page").and_then(Json::as_str).map(str::trim) { + Some("close" | "none" | "") => self.backstage = None, + Some(id) => match backstage::Page::parse(id) { + Some(page) => self.open_backstage(page), + None => { + return Some(Err(format!( + "unknown File page {id:?} (use home, new, open, info, saveAs, print, export, options or close)" + ))); + } + }, + None => self.open_backstage(backstage::Page::Home), + } + Ok(json!({"page": self.backstage.as_ref().map(|b| b.page.id())})) + } "ui.dialog" => { let name = p.get("name").and_then(Json::as_str).unwrap_or(""); self.open_dialog(name, p.clone()); @@ -407,10 +427,28 @@ impl SheetApp { dl(&name, &b); } } + // File › Options (`file.options`) and the File tab itself open the Backstage view. + "options" => self.open_backstage(backstage::Page::Options), + "backstage" => { + let page = params.get("page").and_then(Json::as_str).and_then(backstage::Page::parse).unwrap_or(backstage::Page::Home); + self.open_backstage(page); + } other => self.dialog = dialogs::Dialog::open(self, other, params), } } + /// Opens the File tab's Backstage view on `page`, committing a cell still being edited first + /// (File actions work on the whole workbook). A draft that can't be committed keeps the sheet. + pub fn open_backstage(&mut self, page: backstage::Page) { + if !self.commit_edit(0, 0, false, false) { + return; + } + match &mut self.backstage { + Some(b) => b.page = page, + None => self.backstage = Some(backstage::Backstage::new(page)), + } + } + pub fn open_path(&mut self, path: &str) { match self.run("file.open", json!({"path": path})) { Ok(_) => { @@ -577,8 +615,10 @@ impl SheetApp { .unwrap_or_default(); for (name, bytes) in arrived { let b64 = gridcraft_engine::io::base64_encode(&bytes); - if let Err(e) = self.run("file.open", json!({"name": name, "base64": b64})) { - self.message = Some(("GridCraft".into(), clean_error(&e))); + match self.run("file.open", json!({"name": name, "base64": b64})) { + // A file picked from File › Open (web) arrives here: show it, not the Backstage. + Ok(_) => self.backstage = None, + Err(e) => self.message = Some(("GridCraft".into(), clean_error(&e))), } self.after_engine(); } @@ -605,23 +645,30 @@ impl SheetApp { // The language the widgets translate with this frame (see `i18n::current`). i18n::set_current(&ctx, self.ui.language); let t = theme::Tokens::get(&ctx); - self.ribbon_keys(&ctx); + if self.backstage.is_none() { + self.ribbon_keys(&ctx); + } ribbon::title_bar(self, ui); - ribbon::show(self, ui); - if self.ui.formula_bar { - formula_bar::show(self, ui); + if self.backstage.is_some() { + // File tab: the Backstage view replaces everything below the title bar. + backstage::show(self, ui); + } else { + ribbon::show(self, ui); + if self.ui.formula_bar { + formula_bar::show(self, ui); + } + if self.ui.status_bar { + tabs::status_bar(self, ui); + } + tabs::sheet_tabs(self, ui); + pivot_pane::show(self, ui); + panes::show(self, ui); + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.grid_bg)).show(ui, |ui| { + let g0 = now_ms(); + grid::show(self, ui); + self.perf.grid_ms = now_ms() - g0; + }); } - if self.ui.status_bar { - tabs::status_bar(self, ui); - } - tabs::sheet_tabs(self, ui); - pivot_pane::show(self, ui); - panes::show(self, ui); - egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.grid_bg)).show(ui, |ui| { - let g0 = now_ms(); - grid::show(self, ui); - self.perf.grid_ms = now_ms() - g0; - }); dialogs::show(self, &ctx); widgets::message_box(self, &ctx); widgets::toast(self, &ctx); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 264479a8c..06dee5bd2 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -207,30 +207,15 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { }); } +/// The File tab opens the Backstage view (New, Open, Info, Save As, Print, Export, Options…), like +/// Excel's; the ribbon's tab and collapsed state are left as they were for when it closes. fn file_menu(app: &mut SheetApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - let label = egui::RichText::new("File").font(theme::ui_font(13.5)).color(t.accent); - let file = ui.add_sized(vec2(46.0, 30.0), egui::Button::new(label).frame(false)); - egui::Popup::menu(&file).show(|ui| { - for (label, id, needs_document) in [ - ("New Workbook", "file.new", false), - ("Open…", "file.open", false), - ("Save", "file.save", true), - ("Save As…", "file.saveAs", true), - ("Close", "file.close", true), - ] { - if id == "file.save" || id == "file.close" { - ui.separator(); - } - if ui.add_enabled(!needs_document || app.session.active().is_some(), egui::Button::new(label)).clicked() { - // File operations must include the cell the user is still editing. - if app.commit_edit(0, 0, false, false) { - app.run_or_alert(id, json!({})); - } - ui.close(); - } - } - }); + let label = egui::RichText::new(app.ui.language.tr("File")).font(theme::ui_font(13.5)).color(t.accent); + let file = ui.add(egui::Button::new(label).frame(false).min_size(vec2(46.0, 30.0))); + if file.clicked() { + app.open_backstage(crate::backstage::Page::Home); + } } fn contextual_tabs(app: &SheetApp) -> Vec<&'static str> { diff --git a/crates/ui-egui/tests/file_menu.rs b/crates/ui-egui/tests/file_menu.rs index f3b07f4c8..d47bf5a7f 100644 --- a/crates/ui-egui/tests/file_menu.rs +++ b/crates/ui-egui/tests/file_menu.rs @@ -1,4 +1,4 @@ -//! The shared ribbon exposes basic file operations without requiring a native menu. +//! The File tab opens the Backstage view, which exposes the file operations without a native menu. use std::sync::{Arc, Mutex}; @@ -21,15 +21,26 @@ fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> { h } +/// Opens File (the Backstage view) and clicks a navigation entry or page button. fn choose(h: &mut egui_kittest::Harness<'static, SheetApp>, label: &str) { - h.get_by_label("File").click(); - h.run_steps(3); - h.get_by_label(label).click(); + if h.state().backstage.is_none() { + h.get_by_label("File").click(); + h.run_steps(3); + } + click(h, label); +} + +fn click(h: &mut egui_kittest::Harness<'static, SheetApp>, label: &str) { + h.get_by_role_and_label(egui::accesskit::Role::Button, label).click(); h.run_steps(3); } +fn page(h: &egui_kittest::Harness<'static, SheetApp>) -> Option<&'static str> { + h.state().backstage.as_ref().map(|b| b.page.id()) +} + #[test] -fn new_and_open_are_visible_even_when_the_ribbon_is_collapsed() { +fn new_and_open_from_the_file_tab_keep_the_ribbon_as_it_was() { let opened = Arc::new(Mutex::new(0)); let capture = opened.clone(); let mut h = harness(Services { @@ -42,39 +53,85 @@ fn new_and_open_are_visible_even_when_the_ribbon_is_collapsed() { h.state_mut().ui.ribbon_tab = "Data".into(); h.state_mut().ui.ribbon_collapsed = true; h.run_steps(2); - choose(&mut h, "New Workbook"); + h.get_by_label("File").click(); + h.run_steps(3); + assert_eq!(page(&h), Some("home"), "the File tab opens the Backstage view on Home"); + choose(&mut h, "New"); + assert_eq!(page(&h), Some("new")); + click(&mut h, "Blank workbook"); assert_eq!(h.state().session.documents().len(), 2); - choose(&mut h, "Open…"); - assert_eq!(*opened.lock().unwrap(), 1, "Open invokes the platform file picker"); + assert!(h.state().backstage.is_none(), "creating a workbook returns to the sheet"); + choose(&mut h, "Open"); + click(&mut h, "Browse…"); + assert_eq!(*opened.lock().unwrap(), 1, "Browse invokes the platform file picker"); + assert_eq!(page(&h), Some("open"), "a cancelled pick stays on File › Open"); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(h.state().backstage.is_none(), "Esc returns to the sheet"); assert_eq!(h.state().ui.ribbon_tab, "Data"); assert!(h.state().ui.ribbon_collapsed); - assert!(h.query_by_label("Open…").is_none(), "choosing an action dismisses File"); } #[test] fn save_actions_include_the_cell_draft_and_close_keeps_the_unsaved_prompt() { - let downloads = Arc::new(Mutex::new(Vec::>::new())); + let downloads = Arc::new(Mutex::new(Vec::<(String, Vec)>::new())); let capture = downloads.clone(); - let mut h = harness(Services { download: Some(Box::new(move |_, bytes| capture.lock().unwrap().push(bytes.to_vec()))), ..Default::default() }); + let mut h = harness(Services { + download: Some(Box::new(move |name, bytes| capture.lock().unwrap().push((name.to_string(), bytes.to_vec())))), + ..Default::default() + }); h.input_mut().events.push(egui::Event::Text("123".into())); h.run_steps(2); assert!(h.state().editor.is_some()); choose(&mut h, "Save"); - choose(&mut h, "Save As…"); + assert!(h.state().editor.is_none(), "opening File commits the cell draft"); + assert_eq!(page(&h), Some("saveAs"), "a workbook without a file saves through Save As"); + click(&mut h, "Download (.xlsx)"); + assert!(h.state().backstage.is_none()); + choose(&mut h, "Save As"); + click(&mut h, "Download (.xlsx)"); + choose(&mut h, "Export"); + click(&mut h, "CSV (Comma delimited)"); let saved = downloads.lock().unwrap(); - assert_eq!(saved.len(), 2); - for bytes in saved.iter() { + assert_eq!(saved.len(), 3); + for (_, bytes) in saved.iter().take(2) { let (wb, _) = gridcraft_engine::io::open_bytes("saved.xlsx", bytes).unwrap(); assert_eq!(wb.active().unwrap().value(CellRef::new(0, 0)), Value::Number(123.0)); } + assert!(saved[2].0.ends_with(".csv")); + assert!(String::from_utf8_lossy(&saved[2].1).contains("123")); drop(saved); - assert!(h.state().editor.is_none()); choose(&mut h, "Close"); + assert!(h.state().backstage.is_none()); assert_eq!(h.state().session.documents().len(), 1); assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges")); assert_eq!(h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::new(0, 0)), Value::Number(123.0)); } +#[test] +fn file_options_opens_the_options_page_and_pages_are_reachable_by_command() { + let mut h = harness(Services::default()); + let r = h.state_mut().run("file.options", serde_json::json!({})).unwrap(); + assert_eq!(r["calculation"]["mode"], "automatic"); + h.run_steps(3); + assert_eq!(page(&h), Some("options")); + click(&mut h, "Info"); + assert_eq!(page(&h), Some("info")); + for id in ["print", "export", "saveAs", "new", "open", "home"] { + h.state_mut().run("ui.backstage", serde_json::json!({"page": id})).unwrap(); + h.run_steps(2); + assert_eq!(page(&h), Some(id)); + } + assert!(h.state_mut().run("ui.backstage", serde_json::json!({"page": "account"})).is_err()); + h.state_mut().run("ui.backstage", serde_json::json!({"page": "close"})).unwrap(); + assert!(h.state().backstage.is_none()); + // The back arrow returns to the sheet too. + h.state_mut().run("ui.backstage", serde_json::json!({})).unwrap(); + h.run_steps(2); + click(&mut h, "Back"); + assert!(h.state().backstage.is_none()); +} + #[test] fn export_as_pdf_is_delivered_as_a_download() { let files = Arc::new(Mutex::new(Vec::<(String, Vec)>::new())); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index aeb540d82..09cb59069 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -25,13 +25,14 @@ Errors come back as `{"id": …, "ok": false, "error": "…"}`. | `document.inspect` | | Workbook summary: sheets, used ranges, tables, charts, names, selection, undo labels | | `sheet.read` | `{range?, sheet?, formulas?, formatted?}` | Values (or formulas / displayed text) of a range | | `cell.get` | `{cell?}` | One cell: value, formula, displayed text, style, merge, comment, link | -| `ui.inspect` | | UI state: ribbon tab, editor, open dialog, message, grid rect, scroll, perf | +| `ui.inspect` | | UI state: ribbon tab, editor, open dialog, Backstage page, message, grid rect, scroll, perf | | `ui.click` / `ui.drag` / `ui.move` | `{x, y, toX?, toY?, button?, count?, shift?, cmd?, alt?}` | Real pointer input in window points | | `ui.cell.click` / `ui.cell.drag` | `{cell}` / `{from, to}` | Pointer input at a cell's centre (works in point mode while editing) | | `ui.key` / `ui.text` | `{key, shift?, cmd?, alt?, ctrl?}` / `{text}` | Keyboard input (typing in the grid starts editing) | | `ui.edit.begin` / `ui.edit.text` / `ui.edit.commit` / `ui.edit.cancel` | `{text?}` / `{move?: down\|right\|up\|left\|none}` | Drive the cell editor | | `ui.ribbon` | `{tab}` | Switch ribbon tab | | `ui.dialog` / `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{name}` / `{field, value}` | Open, fill in and confirm dialogs | +| `ui.backstage` (via `engine.execute`) | `{page?: home\|new\|open\|info\|saveAs\|print\|export\|options\|close}` | Open the File tab's Backstage view on a page, or close it (`ui.inspect` reports the open page as `backstage`) | | `ui.screenshot` | `{path?}` | PNG of the window (base64 when no path) | | `ui.set` | `{dark?, formulaBar?, ribbonCollapsed?}` | UI preferences | | `ui.resize` / `ui.focus` | `{width, height}` | Window control | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index e98835d31..d039e3d2d 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -4,11 +4,11 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md). -**Overall: 258 / 290 commands implemented (89.0%).** +**Overall: 259 / 290 commands implemented (89.3%).** | Tab | Implemented | Total | Parity | |---|---|---|---| -| File | 10 | 11 | 91% | +| File | 11 | 11 | 100% | | Home | 94 | 94 | 100% | | Insert | 15 | 25 | 60% | | Draw | 4 | 4 | 100% | @@ -24,7 +24,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as | PivotTable Analyze | 12 | 13 | 92% | | PivotTable Design | 4 | 4 | 100% | -## File (10 / 11, 91%) +## File (11 / 11, 100%) Implemented: @@ -38,9 +38,6 @@ Implemented: - `file.exportCsv` CSV (Export) - `file.exportHtml` Web Page (Export) - `file.properties` Properties (Info) - -Missing: - - `file.options` Options (Options) ## Home (94 / 94, 100%) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e85bca103..9dd4a1de4 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,10 +1,10 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from a source audit and the issue tracker) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (File tab Backstage view, #174) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured -separately in [parity-checklist.md](parity-checklist.md) (258 / 290). +separately in [parity-checklist.md](parity-checklist.md) (259 / 290). Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes), `crates/engine/src/cmd` (shortcuts in command specs), the owner's black-box notes @@ -16,13 +16,13 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes |---|---|---|---| | Title bar, Quick Access Toolbar, AutoSave toggle | yes | yes | done; macOS window controls misaligned (#170), Dock icon size (#169) | | Ribbon: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable tabs) | done; missing contextual tabs for pictures, shapes, sparklines, header/footer, slicers | -| Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing (File ▸ Options is the one missing File command) | +| Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing | | Key tips (Alt / F10 then letters) | yes, every ribbon control | **initial** (#43, 10-10): Alt or F10 shows key tips on Home for alignment, Paste Special and AutoFit plus legacy Alt sequences; other tabs and controls not yet (#33, #49) | 3–5 h | | Formula bar with Name Box, expand, function hints | yes | yes | done | | Sheet tabs: rename, reorder, colour, hide, scroll | yes | yes | rename focus bug on macOS (#145) | | Status bar: Sum/Count/Average and more, view buttons, zoom | yes | yes | customizable statistics partial | | Task panes (Format Chart, Comments, Watch, Selection, PivotTable fields) | yes | yes | element-level Format pane for shapes and pictures missing | -| Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | partial | Options dialog and template gallery missing | +| Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | yes (#174, 10-11): the File tab opens a full-window view (Esc or the back arrow return) with Home, New (blank + the three samples), Open (recent + Browse), Info (location, properties, Protect Workbook), Save, Save As, Print (page preview, active sheet or workbook, fit to page, Page Setup), Export (PDF, CSV, web page), Close and Options (`file.options`: theme, language, formula bar, AutoSave, calculation) | no Account, Share or template gallery beyond the samples; Options applies at once (no OK/Cancel dialog) and holds only the settings GridCraft has | | Dark mode, system appearance | yes | yes | done (10-09) | | Page Layout view (paper view with rulers and header editing) | yes | **no** (Page Break Preview only) | 8–12 h | | Multiple windows per workbook | yes | no (#162) | see [hardware-parity.md](hardware-parity.md) | @@ -91,5 +91,6 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | File tab Backstage view and File › Options (#174) | | 2026-10-10 | minor | Chart resize bug (#190) | | 2026-10-10 | major | Created: chrome, grid, keyboard, dialogs, accessibility; key tips, Page Layout view and accessibility tree flagged |