mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-29 05:06:51 -06:00
Merge pull request #331 from vangel76/feat/file-backstage
UI: File tab Backstage view (#174) # Conflicts: # ROADMAP.md # crates/engine/src/tests.rs # crates/ui-egui/src/control.rs # crates/ui-egui/src/lib.rs # crates/ui-egui/src/ribbon.rs # docs/parity-checklist.md # docs/ui-parity.md
This commit is contained in:
@@ -149,6 +149,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-11 | PivotTable calculated fields (`pivot.calculatedField`), evaluated on sums like Excel, with XLSX round-trip (#304) |
|
||||
| 2026-10-11 | PivotCharts (`insert.pivotChart`): charts bound to a PivotTable's report, following refreshes and layout changes (#311) |
|
||||
| 2026-10-11 | FORECAST.ETS, FORECAST.ETS.CONFINT, FORECAST.ETS.SEASONALITY, FORECAST.ETS.STAT: AAA exponential smoothing forecasts (#314) |
|
||||
| 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 |
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -1303,6 +1303,22 @@ fn checkboxes_survive_xlsx_as_cell_controls() {
|
||||
assert_eq!(v(&s, "A2"), Value::Bool(false));
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
@@ -173,6 +174,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
|
||||
("view.theme", "Display Theme", ""),
|
||||
("view.interfaceScale", "Interface Size", "{percent: 75–200 | \"auto\"} (100 = auto; clamped)"),
|
||||
("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\"|\"de\"} (alias: code)"),
|
||||
("app.language.english", "Interface Language: English", ""),
|
||||
("app.language.japanese", "Interface Language: Japanese", ""),
|
||||
|
||||
@@ -438,6 +438,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)]
|
||||
|
||||
@@ -282,6 +282,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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -283,6 +283,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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -282,6 +282,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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -282,6 +282,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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -115,6 +115,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> {
|
||||
@@ -631,6 +637,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
@@ -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;
|
||||
@@ -169,6 +170,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,
|
||||
@@ -201,6 +204,7 @@ impl SheetApp {
|
||||
editor: None,
|
||||
text_box_editor: None,
|
||||
dialog: None,
|
||||
backstage: None,
|
||||
message: None,
|
||||
toast: None,
|
||||
services,
|
||||
@@ -396,6 +400,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());
|
||||
@@ -464,10 +484,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(_) => {
|
||||
@@ -644,8 +682,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();
|
||||
}
|
||||
@@ -672,23 +712,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.session.prefs.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.session.prefs.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);
|
||||
|
||||
@@ -239,33 +239,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();
|
||||
}
|
||||
}
|
||||
// Excel keeps the theme under File › Account › Office Theme (#194).
|
||||
ui.separator();
|
||||
ui.menu_button(app.ui.language.tr("Display theme"), |ui| theme_choices(app, ui));
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// System / Light / Dark, as in the View tab's Display theme box (`view.theme`).
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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 (`formulaBar` runs `view.formulaBar`) |
|
||||
| `ui.resize` / `ui.focus` | `{width, height}` | Window control |
|
||||
|
||||
@@ -8,7 +8,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as
|
||||
|
||||
| Tab | Implemented | Total | Parity |
|
||||
|---|---|---|---|
|
||||
| File | 10 | 11 | 91% |
|
||||
| File | 11 | 11 | 100% |
|
||||
| Home | 94 | 94 | 100% |
|
||||
| Insert | 16 | 25 | 64% |
|
||||
| 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 | 13 | 13 | 100% |
|
||||
| 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%)
|
||||
|
||||
+4
-1
@@ -16,7 +16,7 @@ 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) |
|
||||
@@ -24,6 +24,8 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes
|
||||
| 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 |
|
||||
| Dark mode, system appearance | yes | yes | done (10-09); follows the system on first run, title-bar switch and File › Display theme (#194, #239) |
|
||||
| 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) |
|
||||
|
||||
@@ -148,5 +150,6 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
|
||||
| 2026-10-10 | minor | Keep formula helpers inside the worksheet when scrolling (#200) |
|
||||
| 2026-10-11 | trivial | Theme: title-bar light/dark switch, File › Display theme, system appearance on first run (#194, #239) |
|
||||
| 2026-10-11 | minor | Keyboard shortcut audit against Microsoft's Windows and Mac lists; missing shortcuts for existing features added (#110) |
|
||||
| 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 |
|
||||
|
||||
Reference in New Issue
Block a user