UI: File tab Backstage view (#174)

Clicking the File tab now opens a full-window Backstage view in place of the
ribbon, formula bar, grid and sheet tabs, like Excel's. Esc or the back arrow
return to the sheet. Pages: Home (new + recent), New (blank + samples), Open
(recent + Browse), Info (location, properties, Protect Workbook), Save, Save As,
Print (page preview, scope, fit to page, Page Setup), Export (PDF, CSV, web
page), Close and Options (theme, language, formula bar, AutoSave, calculation).
Every action runs an existing command or dialog.

Adds `file.options` (reports the workbook's calculation settings and asks the
interface for the Options page) and the UI command `ui.backstage {page}`.
Keyboard shortcuts and the macOS native menu are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-11 13:03:11 +02:00
co-authored by Claude Opus 5.5
parent 9f896c1249
commit b4d823902a
17 changed files with 1348 additions and 66 deletions
+1
View File
@@ -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 |
+26 -1
View File
@@ -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<CommandSpec> {
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<Json> {
})
}
/// 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<Json> {
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<Json> {
let i = u32_param(p, "index").ok_or_else(|| bad("window.activate", "missing `index`"))? as usize;
if i >= s.documents().len() {
+16
View File
@@ -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();
+828
View File
@@ -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<Page> {
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<Page>,
}
/// 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<usize>) -> 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::<Vec<_>>() {
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<String> = {
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<Json> = 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()));
}
}
+2
View File
@@ -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", ""),
+61
View File
@@ -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)]
+59
View File
@@ -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,
})
}
+59
View File
@@ -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,
})
}
+59
View File
@@ -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,
})
}
+55
View File
@@ -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,
})
}
+29
View File
@@ -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);
}
}
}
+64 -17
View File
@@ -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<editor::EditState>,
pub text_box_editor: Option<text_box::EditState>,
pub dialog: Option<dialogs::Dialog>,
/// The File tab's Backstage view, when open (UI-only; replaces the ribbon, grid and tabs).
pub backstage: Option<backstage::Backstage>,
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);
+7 -22
View File
@@ -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> {
+72 -15
View File
@@ -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::<Vec<u8>>::new()));
let downloads = Arc::new(Mutex::new(Vec::<(String, Vec<u8>)>::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<u8>)>::new()));
+2 -1
View File
@@ -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 |
+3 -6
View File
@@ -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%)
+5 -4
View File
@@ -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 |