View: Page Layout view with rulers and in-place headers and footers (#329)

View › Page Layout (and the status-bar button) now shows the sheet as printed
pages on a desk instead of overlaying page outlines on the grid.

- Model/XLSX: per-sheet view mode (normal / pageLayout / pageBreakPreview),
  saved as <sheetView view="…"> and read back. view.* commands set it per
  sheet ({sheet?}) without an undo step or dirtying a clean workbook.
- Engine: paper::PageGrid paginates the sheet from A1 with the print
  pagination (print::split_bands, shared with PDF export and printing) and
  maps cells to pages and back; header/footer section helpers
  (split/join, &[Page] field names) shared with the PDF header code.
  pageLayout.headerFooter takes {part, section, text} for one section and a
  sheet; header/footer strings are capped at 255 characters.
  view.ruler toggles the rulers.
- UI: Geo carries the page grid, so painting, hit-testing, selection,
  editing, objects and scrolling work through the page transform; frozen
  panes are ignored in this view. Pages with margins and gaps, "Click to add
  data" pages, rulers in inches (English) or centimetres, headers/footers
  drawn on printed pages, click a margin section to edit it in place; a
  click elsewhere commits (one undo step), Escape cancels. Header & Footer
  contextual tab: presets, Page Number, Number of Pages, Current Date/Time,
  File Path, File Name, Sheet Name, Go to Header/Footer. View › Ruler.
- New strings in every catalog; new code-drawn icons; desk colour token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-11 13:24:06 +02:00
co-authored by Claude Opus 5.5
parent 9f896c1249
commit 001f025119
28 changed files with 1789 additions and 103 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 | Page Layout view: pages with margins and rulers, headers and footers edited in place, Header & Footer tab, view saved per sheet in XLSX (#329) |
| 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 |
+44 -7
View File
@@ -116,9 +116,29 @@ pub fn specs() -> Vec<CommandSpec> {
rotate
),
// Views
cmd!(noundo "view.normal", "Normal", ["View", "Workbook Views"], None, "{}", has_doc, |s, _| set_view(s, "normal")),
cmd!(noundo "view.pageLayout", "Page Layout", ["View", "Workbook Views"], None, "{}", has_doc, |s, _| set_view(s, "pageLayout")),
cmd!(noundo "view.pageBreakPreview", "Page Break Preview", ["View", "Workbook Views"], None, "{}", has_doc, |s, _| set_view(s, "pageBreakPreview")),
cmd!(noundo "view.normal", "Normal", ["View", "Workbook Views"], None, "{sheet?} → {view} (per sheet, saved with the file)", has_doc, |s, p| set_view(
s,
p,
SheetViewMode::Normal
)),
cmd!(
noundo "view.pageLayout",
"Page Layout",
["View", "Workbook Views"],
None,
"{sheet?} → {view}: pages on paper with rulers; headers and footers are edited in place (per sheet, saved with the file)",
has_doc,
|s, p| set_view(s, p, SheetViewMode::PageLayout)
),
cmd!(
noundo "view.pageBreakPreview",
"Page Break Preview",
["View", "Workbook Views"],
None,
"{sheet?} → {view} (per sheet, saved with the file)",
has_doc,
|s, p| set_view(s, p, SheetViewMode::PageBreakPreview)
),
cmd!(noundo "view.customViews", "Custom Views…", ["View", "Workbook Views"], None, "{save?: name, show?: name, delete?: name} → list", has_doc, custom_views),
cmd!(noundo "view.split", "Split", ["View", "Window"], None, "{} (toggles a split at the active cell; shown as frozen panes in this version)", has_doc, split),
cmd!(noundo "view.newWindow", "New Window", ["View", "Window"], None, "{} opens a second window on the same workbook (a linked copy)", has_doc, new_window),
@@ -126,7 +146,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "view.hideWindow", "Hide", ["View", "Window"], None, "{}", has_doc, hide_window),
cmd!(noundo "view.unhideWindow", "Unhide…", ["View", "Window"], None, "{index?}", has_doc, unhide_window),
cmd!(query "view.switchWindows", "Switch Windows", ["View", "Window"], None, "{} → open windows", always, switch_windows),
cmd!(noundo "view.ruler", "Ruler", ["View", "Show"], None, "{on?}", has_doc, |_, _| ok()),
cmd!(noundo "view.ruler", "Ruler", ["View", "Show"], None, "{on?} → {on}: rulers in Page Layout view (an application option)", has_doc, ruler),
cmd!(query "view.macros", "Macros", ["View", "Macros"], None, "{} → scripts", has_doc, all_scripts),
// Table tools
cmd!("table.resize", "Resize Table", ["Table Design", "Properties"], None, "{table?, range: \"A1:F20\"}", has_doc, table_resize),
@@ -540,9 +560,26 @@ fn rotate(s: &mut Session, p: &Json) -> Result<Json> {
with_shape(s, p, |sh| std::mem::swap(&mut sh.anchor.width, &mut sh.anchor.height))
}
fn set_view(s: &mut Session, mode: &str) -> Result<Json> {
s.view_mode = mode.to_string();
Ok(json!({"view": mode}))
/// Switches a sheet's view. Like zoom, the view is saved with the file but is not an edit: it
/// neither records an undo step nor marks a clean workbook as changed.
fn set_view(s: &mut Session, p: &Json, mode: SheetViewMode) -> Result<Json> {
let si = target_sheet(s, p)?;
let d = s.doc_mut()?;
let dirty = d.is_dirty();
let wb = std::sync::Arc::make_mut(&mut d.wb);
if let Some(sh) = wb.sheet_mut(si) {
sh.view = mode;
}
if !dirty {
d.saved = d.wb.clone();
}
Ok(json!({"view": mode.as_str()}))
}
fn ruler(s: &mut Session, p: &Json) -> Result<Json> {
let on = bool_param(p, "on").unwrap_or(s.hide_ruler);
s.hide_ruler = !on;
Ok(json!({"on": on}))
}
const VIEWS_NAME: &str = "_sc_custom_views";
+222 -27
View File
@@ -58,7 +58,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
/// Sheet geometry is in pixels at 96 dpi; PDF units are points.
const PX_TO_PT: f32 = 0.75;
pub(crate) const PX_TO_PT: f32 = 0.75;
/// Most pages one export produces.
pub const MAX_PAGES: usize = 2000;
/// Cells in a page block above which only stored cells are visited (not every grid position).
@@ -110,13 +110,15 @@ pub struct SheetLayout {
/// Heading sizes in sheet pixels (0 when headings are off).
pub heading_w: f32,
pub heading_h: f32,
/// Manual page breaks apply (they don't when the sheet is fitted to pages).
pub manual_breaks: bool,
}
impl SheetLayout {
fn k(&self) -> f32 {
PX_TO_PT * self.scale
}
fn avail(&self) -> (f32, f32) {
pub(crate) fn avail(&self) -> (f32, f32) {
let [l, r, t, b, ..] = self.margins;
((self.page_w - l - r).max(36.0), (self.page_h - t - b).max(36.0))
}
@@ -271,30 +273,11 @@ pub fn layout_sheet(wb: &Workbook, si: usize, opts: &PrintOptions, range_overrid
title_cols,
heading_w,
heading_h,
manual_breaks: fit.is_none(),
};
let paginate = |lay: &mut SheetLayout, manual: bool| {
let k = lay.k();
let (aw, ah) = lay.avail();
let (aw, ah) = (aw / k, ah / k);
let rb: Vec<u32> = if manual { st.row_breaks.clone() } else { vec![] };
let cb: Vec<u32> = if manual { st.col_breaks.clone() } else { vec![] };
let (mut rb, mut cb) = (rb, cb);
rb.sort_unstable();
cb.sort_unstable();
let title_h = |start: u32| -> f32 {
match title_rows {
Some((a, b)) if a < start => (a..=b.min(start - 1)).map(|r| sh.row_height(r)).sum(),
_ => 0.0,
}
};
let title_w = |start: u32| -> f32 {
match title_cols {
Some((a, b)) if a < start => (a..=b.min(start - 1)).map(|c| sh.col_width(c)).sum(),
_ => 0.0,
}
};
lay.row_bands = bands(area.start.row, area.end.row, |r| sh.row_height(r), |start| (ah - heading_h - title_h(start)).max(1.0), &rb, MAX_PAGES);
lay.col_bands = bands(area.start.col, area.end.col, |c| sh.col_width(c), |start| (aw - heading_w - title_w(start)).max(1.0), &cb, MAX_PAGES);
lay.manual_breaks = manual;
(lay.row_bands, lay.col_bands) = split_bands(sh, lay, MAX_PAGES);
lay.pages.clear();
'outer: for ci in 0..lay.col_bands.len() {
for ri in 0..lay.row_bands.len() {
@@ -332,6 +315,37 @@ pub fn layout_sheet(wb: &Workbook, si: usize, opts: &PrintOptions, range_overrid
Some(lay)
}
/// Splits a layout's area into row and column bands that fit its pages (at most `cap` of each),
/// honouring manual breaks (when `lay.manual_breaks`), print headings and repeated titles. The
/// one pagination rule shared by printing, PDF export, Page Break Preview and Page Layout view.
pub(crate) fn split_bands(sh: &Sheet, lay: &SheetLayout, cap: usize) -> (Vec<(u32, u32)>, Vec<(u32, u32)>) {
let k = lay.k();
let (aw, ah) = lay.avail();
let (aw, ah) = (aw / k, ah / k);
let st = &lay.settings;
let mut rb: Vec<u32> = if lay.manual_breaks { st.row_breaks.clone() } else { vec![] };
let mut cb: Vec<u32> = if lay.manual_breaks { st.col_breaks.clone() } else { vec![] };
rb.sort_unstable();
cb.sort_unstable();
let (title_rows, title_cols) = (lay.title_rows, lay.title_cols);
let title_h = |start: u32| -> f32 {
match title_rows {
Some((a, b)) if a < start => (a..=b.min(start - 1)).map(|r| sh.row_height(r)).sum(),
_ => 0.0,
}
};
let title_w = |start: u32| -> f32 {
match title_cols {
Some((a, b)) if a < start => (a..=b.min(start - 1)).map(|c| sh.col_width(c)).sum(),
_ => 0.0,
}
};
let area = lay.area;
let rows = bands(area.start.row, area.end.row, |r| sh.row_height(r), |start| (ah - lay.heading_h - title_h(start)).max(1.0), &rb, cap);
let cols = bands(area.start.col, area.end.col, |c| sh.col_width(c), |start| (aw - lay.heading_w - title_w(start)).max(1.0), &cb, cap);
(rows, cols)
}
/// Lays out every sheet a print command covers.
pub fn layouts(wb: &Workbook, opts: &PrintOptions) -> Vec<SheetLayout> {
let mut out = Vec::new();
@@ -375,6 +389,132 @@ pub struct HfContext {
pub time: String,
}
/// Today's date and the current time as headers and footers show them (`&D`, `&T`).
pub fn hf_date_time(wb: &Workbook) -> (String, String) {
let now = gridcraft_calc::now_serial();
let date = crate::display::format(&Value::Number(now.floor()), "m/d/yyyy", wb).text;
let time = crate::display::format(&Value::Number(now), "h:mm AM/PM", wb).text;
(date, time)
}
/// Header/footer codes and the field names the Page Layout view shows for them while editing.
pub const HF_FIELDS: [(char, &str); 8] = [
('P', "&[Page]"),
('N', "&[Pages]"),
('D', "&[Date]"),
('T', "&[Time]"),
('Z', "&[Path]"),
('F', "&[File]"),
('A', "&[Tab]"),
('G', "&[Picture]"),
];
/// Splits a header/footer string into its raw left, centre and right sections (codes kept).
/// Text before the first `&L`/`&C`/`&R` belongs to the centre, as when printing.
pub fn split_header_footer(spec: &str) -> [String; 3] {
let mut out: [String; 3] = Default::default();
let mut cur = 1usize;
let chars: Vec<char> = spec.chars().take(10_000).collect();
let mut i = 0;
while let Some(&c) = chars.get(i) {
let next = chars.get(i + 1).copied();
let sec = cur.min(2);
match (c, next) {
('&', Some('L' | 'l')) => cur = 0,
('&', Some('C' | 'c')) => cur = 1,
('&', Some('R' | 'r')) => cur = 2,
('&', Some('"')) => {
// &"Font,Style" is kept whole.
let end = chars[i + 2..].iter().position(|ch| *ch == '"').map_or(chars.len(), |p| i + 3 + p);
out[sec].extend(&chars[i..end.min(chars.len())]);
i = end;
continue;
}
('&', Some(n)) => {
out[sec].push('&');
out[sec].push(n);
}
('&', None) => {}
(ch, _) => {
out[sec].push(ch);
i += 1;
continue;
}
}
i += 2;
}
out
}
/// Joins raw left, centre and right sections into one header/footer string.
pub fn join_header_footer(sections: &[String; 3]) -> String {
let mut s = String::new();
for (code, text) in ["&L", "&C", "&R"].iter().zip(sections) {
if !text.is_empty() {
s.push_str(code);
s.push_str(text);
}
}
s
}
/// A raw section as the user edits it: `&P` shows as `&[Page]`, `&N` as `&[Pages]`, and so on.
pub fn codes_to_fields(raw: &str) -> String {
let mut out = String::new();
let mut it = raw.chars().peekable();
while let Some(c) = it.next() {
if c != '&' {
out.push(c);
continue;
}
// A dangling `&` at the end is dropped (printing ignores it too).
if let Some(n) = it.next() {
match HF_FIELDS.iter().find(|(code, _)| *code == n.to_ascii_uppercase()) {
Some((_, field)) => out.push_str(field),
None => {
out.push('&');
out.push(n);
}
}
}
}
out
}
/// The inverse of [`codes_to_fields`]: field names become codes again; a dangling `&` at the end
/// becomes a literal ampersand so it can't swallow the next section's code.
pub fn fields_to_codes(text: &str) -> String {
let chars: Vec<char> = text.chars().take(10_000).collect();
let mut out = String::new();
let mut i = 0;
while let Some(&c) = chars.get(i) {
if c != '&' {
out.push(c);
i += 1;
continue;
}
if chars.get(i + 1) == Some(&'&') {
out.push_str("&&");
i += 2;
continue;
}
let rest: String = chars[i..].iter().take(12).collect();
if let Some((code, field)) = HF_FIELDS.iter().find(|(_, f)| rest.starts_with(f)) {
out.push('&');
out.push(*code);
i += field.chars().count();
continue;
}
if i + 1 == chars.len() {
out.push_str("&&");
} else {
out.push('&');
}
i += 1;
}
out
}
/// One header/footer section: its text and whether `&B` made it bold.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct HfSection {
@@ -1286,9 +1426,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
doc.info.title = if wb.props.title.is_empty() { file.to_string() } else { wb.props.title.clone() };
doc.info.author = wb.props.author.clone();
let total: usize = layouts.iter().map(|l| l.pages.len()).sum();
let now = gridcraft_calc::now_serial();
let date = crate::display::format(&Value::Number(now.floor()), "m/d/yyyy", wb).text;
let time = crate::display::format(&Value::Number(now), "h:mm AM/PM", wb).text;
let (date, time) = hf_date_time(wb);
let mut page_no = 0usize;
for lay in layouts {
let Some(sh) = wb.sheet(lay.sheet) else { continue };
@@ -1587,6 +1725,63 @@ mod tests {
hay.windows(needle.len()).any(|w| w == needle.as_bytes())
}
#[test]
fn header_sections_split_join_and_field_names() {
let spec = "&L&\"Arial,Bold\"Sales &&Co&CPage &P of &N&R&D";
let parts = split_header_footer(spec);
assert_eq!(parts, ["&\"Arial,Bold\"Sales &&Co".to_string(), "Page &P of &N".into(), "&D".into()]);
assert_eq!(join_header_footer(&parts), spec);
// Text before any section code is the centre section, as when printing.
assert_eq!(split_header_footer("Report"), [String::new(), "Report".into(), String::new()]);
assert_eq!(codes_to_fields("Page &P of &N, &A (&F) &&"), "Page &[Page] of &[Pages], &[Tab] (&[File]) &&");
assert_eq!(fields_to_codes("Page &[Page] of &[Pages], &[Tab] (&[File]) &&"), "Page &P of &N, &A (&F) &&");
// A dangling ampersand stays literal instead of eating the next section's code.
assert_eq!(fields_to_codes("R&"), "R&&");
let joined = join_header_footer(&[fields_to_codes("R&"), "x".into(), String::new()]);
assert_eq!(split_header_footer(&joined)[0], "R&&");
let cx = HfContext { page: 2, pages: 5, sheet: "Data".into(), ..Default::default() };
let out = expand_header_footer(&join_header_footer(&[String::new(), fields_to_codes("&[Page] / &[Pages]"), fields_to_codes("&[Tab]")]), &cx);
assert_eq!((out[1].text.as_str(), out[2].text.as_str()), ("2 / 5", "Data"));
// Hostile input never panics.
for t in ["&", "&\"", "&\"unterminated", "&&&", "&[Pag", "\u{1F600}&[Page]"] {
let _ = join_header_footer(&split_header_footer(t));
let _ = fields_to_codes(&codes_to_fields(t));
}
}
#[test]
fn header_footer_section_edits_are_undoable_and_views_are_per_sheet() {
let mut s = s();
s.execute("pageLayout.headerFooter", json!({"header": "&LLeft&RRight"})).unwrap();
let r = s.execute("pageLayout.headerFooter", json!({"part": "header", "section": "center", "text": "Page &[Page] of &[Pages]"})).unwrap();
assert_eq!(r["header"], "&LLeft&CPage &P of &N&RRight");
s.execute("pageLayout.headerFooter", json!({"part": "footer", "section": "right", "text": "&[Date]"})).unwrap();
assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().print.footer, "&R&D");
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().print.header, "&LLeft&RRight");
assert!(s.execute("pageLayout.headerFooter", json!({"part": "middle", "text": "x"})).is_err());
assert!(s.execute("pageLayout.headerFooter", json!({"section": "top"})).is_err());
let long = "x".repeat(1000);
s.execute("pageLayout.headerFooter", json!({"part": "header", "section": "left", "text": long})).unwrap();
assert!(s.doc().unwrap().wb.sheet(0).unwrap().print.header.chars().count() <= 255);
// The view is per sheet and doesn't dirty a clean workbook.
let mut fresh = Session::new();
fresh.new_workbook();
fresh.execute("home.insertSheet", json!({})).unwrap();
fresh.doc_mut().unwrap().saved = fresh.doc().unwrap().wb.clone();
fresh.execute("view.pageLayout", json!({"sheet": 0})).unwrap();
let wb = &fresh.doc().unwrap().wb;
assert_eq!((wb.sheet(0).unwrap().view, wb.sheet(1).unwrap().view), (SheetViewMode::PageLayout, SheetViewMode::Normal));
assert!(!fresh.doc().unwrap().is_dirty(), "switching views is not an edit");
s.execute("view.pageLayout", json!({"sheet": 0})).unwrap();
s.execute("view.normal", json!({"sheet": 0})).unwrap();
assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().view, SheetViewMode::Normal);
assert_eq!(s.execute("view.ruler", json!({"on": false})).unwrap()["on"], false);
assert!(s.hide_ruler);
assert_eq!(s.execute("view.ruler", json!({})).unwrap()["on"], true);
}
#[test]
fn page_count_300_rows_letter_portrait() {
let mut s = s();
+36 -5
View File
@@ -91,7 +91,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Header & Footer",
["Insert", "Text"],
None,
"{header?: \"&C&P\", footer?: \"&CPage &P of &N\"}",
"{sheet?, header?: \"&C&P\", footer?: \"&CPage &P of &N\"} or one section: {sheet?, part: header|footer, section: left|center|right, text: \"Page &[Page] of &[Pages]\"} (field names or codes) → {header, footer}",
has_doc,
header_footer
),
@@ -281,17 +281,48 @@ fn breaks(s: &mut Session, p: &Json) -> Result<Json> {
})
}
/// Excel keeps at most 255 characters per header or footer.
const MAX_HEADER_FOOTER: usize = 255;
fn header_footer(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "pageLayout.headerFooter";
let si = target_sheet(s, p)?;
let cap = |t: &str| t.chars().take(MAX_HEADER_FOOTER).collect::<String>();
// One section edited in place (Page Layout view).
let section = match (str_param(p, "part"), str_param(p, "section")) {
(None, None) => None,
(part, sec) => {
let footer = match part.unwrap_or("header") {
"header" => false,
"footer" => true,
other => return Err(bad(CMD, format!("`part` must be header or footer, not `{other}`"))),
};
let idx = match sec.unwrap_or("center") {
"left" => 0usize,
"center" | "centre" => 1,
"right" => 2,
other => return Err(bad(CMD, format!("`section` must be left, center or right, not `{other}`"))),
};
Some((footer, idx, super::print::fields_to_codes(str_param(p, "text").unwrap_or(""))))
}
};
edit(s, |cx| {
let si = cx.sheet_index();
let pr = &mut cx.sheet_mut(si)?.print;
if let Some(h) = str_param(p, "header") {
pr.header = h.to_string();
pr.header = cap(h);
}
if let Some(f) = str_param(p, "footer") {
pr.footer = f.to_string();
pr.footer = cap(f);
}
Ok(Json::Null)
if let Some((footer, idx, text)) = &section {
let spec = if *footer { &mut pr.footer } else { &mut pr.header };
let mut parts = super::print::split_header_footer(spec);
if let Some(slot) = parts.get_mut(*idx) {
*slot = text.clone();
}
*spec = cap(&super::print::join_header_footer(&parts));
}
Ok(json!({"header": pr.header, "footer": pr.footer}))
})
}
+3 -2
View File
@@ -12,6 +12,7 @@ pub mod cmd;
pub mod display;
pub mod fill;
pub mod io;
pub mod paper;
pub mod pivot;
pub mod sample;
pub mod selection;
@@ -190,8 +191,8 @@ pub struct Session {
pub format_painter: Option<(Arc<Workbook>, usize, RangeRef, bool)>,
/// Find state (last search).
pub last_find: Option<Json>,
/// Workbook view: normal, pageLayout or pageBreakPreview.
pub view_mode: String,
/// View › Show › Ruler is off (rulers in Page Layout view; an application option).
pub hide_ruler: bool,
/// Journal position where action recording started.
pub recording: Option<usize>,
/// Windows hidden with View › Hide.
+322
View File
@@ -0,0 +1,322 @@
//! Page Layout view geometry: a sheet shown as printed pages side by side.
//!
//! The view uses the print pagination ([`crate::cmd::print::split_bands`]) over the sheet from
//! A1, so its page edges are the printed ones whenever the print area starts at A1. Pages keep
//! going right and down past the data ("Click to add data" pages), as far as the view needs.
//!
//! Coordinates are *layout units*: sheet pixels (96 dpi) at the print scale, so a cell keeps its
//! sheet size and a page is `paper / scale` big. The UI multiplies by its zoom. Pages sit in a
//! grid with a gap around each: page `(row band, column band)` starts at
//! `(gap + j·(page_w + gap), gap + i·(page_h + gap))`, and its cells start at its top-left margin.
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef};
use gridcraft_model::{LineIndex, Sheet, Workbook};
use crate::cmd::print::{self, PX_TO_PT, PrintOptions};
/// Gap between pages on screen, in pixels at 100% zoom.
pub const PAGE_GAP: f32 = 14.0;
/// One sheet paginated for the Page Layout view.
#[derive(Clone, Debug)]
pub struct PageGrid {
/// Page size in layout units.
pub page_w: f64,
pub page_h: f64,
/// Left, right, top, bottom, header and footer margins in layout units.
pub margins: [f64; 6],
/// Gap between pages in layout units.
pub gap: f64,
/// Print scale (1.0 = 100%): screen sizes of cells are `zoom × scale`.
pub scale: f32,
/// Paper size in points (after orientation), for the rulers.
pub paper_pt: (f32, f32),
pub row_bands: Vec<(u32, u32)>,
pub col_bands: Vec<(u32, u32)>,
/// The bands reach the sheet's last row / column.
pub rows_complete: bool,
pub cols_complete: bool,
/// Bands holding printed data: `[first, last]` row bands and column bands.
pub data_rows: (usize, usize),
pub data_cols: (usize, usize),
ri: LineIndex,
ci: LineIndex,
}
/// A header or footer zone of one page.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct HfZone {
pub footer: bool,
/// 0 left, 1 centre, 2 right.
pub section: usize,
}
impl PageGrid {
/// Paginates sheet `si` far enough to cover row `need_row` and column `need_col` (and at
/// least the printed area). `None` when the sheet doesn't exist.
pub fn build(wb: &Workbook, si: usize, need_row: u32, need_col: u32) -> Option<PageGrid> {
let sh = wb.sheet(si)?;
let printed = print::layout_sheet(wb, si, &PrintOptions::default(), None)?;
let mut lay = printed.clone();
let end_row = need_row.max(printed.area.end.row).min(MAX_ROWS - 1);
let end_col = need_col.max(printed.area.end.col).min(MAX_COLS - 1);
lay.area = RangeRef::new(CellRef::new(0, 0), CellRef::new(end_row, end_col));
let (row_bands, col_bands) = print::split_bands(sh, &lay, MAX_ROWS as usize);
let s = f64::from(lay.scale.clamp(0.1, 4.0));
let to_layout = |pt: f32| f64::from(pt) / f64::from(PX_TO_PT) / s;
let margins = lay.margins.map(to_layout);
let band_range = |bands: &[(u32, u32)], lo: u32, hi: u32| {
let first = bands.iter().position(|b| b.1 >= lo).unwrap_or(0);
let last = bands.iter().rposition(|b| b.0 <= hi).unwrap_or(first).max(first);
(first, last)
};
let area = printed.area;
let data_rows = band_range(&row_bands, area.start.row, area.end.row);
let data_cols = band_range(&col_bands, area.start.col, area.end.col);
Some(PageGrid {
page_w: to_layout(lay.page_w),
page_h: to_layout(lay.page_h),
margins,
gap: f64::from(PAGE_GAP) / s,
scale: lay.scale,
paper_pt: (lay.page_w, lay.page_h),
rows_complete: row_bands.last().is_some_and(|b| b.1 >= MAX_ROWS - 1),
cols_complete: col_bands.last().is_some_and(|b| b.1 >= MAX_COLS - 1),
row_bands,
col_bands,
data_rows,
data_cols,
ri: LineIndex::new(&sh.rows, sh.default_row_height, MAX_ROWS),
ci: LineIndex::new(&sh.cols, sh.default_col_width, MAX_COLS),
})
}
/// The grid covers row `r` and column `c` (else it needs a rebuild with more pages).
pub fn covers(&self, r: u32, c: u32) -> bool {
(self.rows_complete || self.row_bands.last().is_some_and(|b| b.1 >= r))
&& (self.cols_complete || self.col_bands.last().is_some_and(|b| b.1 >= c))
}
fn band_of(bands: &[(u32, u32)], line: u32) -> usize {
bands.partition_point(|b| b.1 < line).min(bands.len().saturating_sub(1))
}
/// Left edge of page column `j` / top edge of page row `i`.
pub fn page_x(&self, j: usize) -> f64 {
self.gap + j as f64 * (self.page_w + self.gap)
}
pub fn page_y(&self, i: usize) -> f64 {
self.gap + i as f64 * (self.page_h + self.gap)
}
/// The page rectangle `(x, y, w, h)` of page `(i, j)`.
pub fn page_rect(&self, i: usize, j: usize) -> (f64, f64, f64, f64) {
(self.page_x(j), self.page_y(i), self.page_w, self.page_h)
}
/// Where the cells of page `(i, j)` go: inside the margins.
pub fn content_rect(&self, i: usize, j: usize) -> (f64, f64, f64, f64) {
let [l, r, t, b, ..] = self.margins;
(self.page_x(j) + l, self.page_y(i) + t, (self.page_w - l - r).max(1.0), (self.page_h - t - b).max(1.0))
}
/// Left edge of column `col`.
pub fn col_x(&self, col: u32) -> f64 {
let j = Self::band_of(&self.col_bands, col);
let start = self.col_bands.get(j).map_or(0, |b| b.0);
self.page_x(j) + self.margins[0] + (self.ci.start(col) - self.ci.start(start))
}
/// Top edge of row `row`.
pub fn row_y(&self, row: u32) -> f64 {
let i = Self::band_of(&self.row_bands, row);
let start = self.row_bands.get(i).map_or(0, |b| b.0);
self.page_y(i) + self.margins[2] + (self.ri.start(row) - self.ri.start(start))
}
/// Page column under `x` (the nearest one in gaps).
pub fn page_col_at(&self, x: f64) -> usize {
let j = ((x - self.gap / 2.0) / (self.page_w + self.gap)).floor();
(j.max(0.0) as usize).min(self.col_bands.len().saturating_sub(1))
}
pub fn page_row_at(&self, y: f64) -> usize {
let i = ((y - self.gap / 2.0) / (self.page_h + self.gap)).floor();
(i.max(0.0) as usize).min(self.row_bands.len().saturating_sub(1))
}
/// Column under `x`, clamped to the page's columns (margins and gaps hit the nearest one).
pub fn col_at(&self, x: f64) -> u32 {
let j = self.page_col_at(x);
let Some(&(a, b)) = self.col_bands.get(j) else { return 0 };
let local = x - self.page_x(j) - self.margins[0];
if local <= 0.0 { a } else { self.ci.at(self.ci.start(a) + local).clamp(a, b) }
}
pub fn row_at(&self, y: f64) -> u32 {
let i = self.page_row_at(y);
let Some(&(a, b)) = self.row_bands.get(i) else { return 0 };
let local = y - self.page_y(i) - self.margins[2];
if local <= 0.0 { a } else { self.ri.at(self.ri.start(a) + local).clamp(a, b) }
}
/// The page `(i, j)` whose paper contains the point, if any.
pub fn page_at(&self, x: f64, y: f64) -> Option<(usize, usize)> {
let (i, j) = (self.page_row_at(y), self.page_col_at(x));
let (px, py, w, h) = self.page_rect(i, j);
(x >= px && x < px + w && y >= py && y < py + h).then_some((i, j))
}
/// The point lies on cells (inside a page's margins, on a cell of that page).
pub fn on_cells(&self, x: f64, y: f64) -> bool {
let Some((i, j)) = self.page_at(x, y) else { return false };
let (cx, cy, w, h) = self.content_rect(i, j);
let (Some(&(_, c1)), Some(&(_, r1))) = (self.col_bands.get(j), self.row_bands.get(i)) else { return false };
// Cells end where the band ends (the rest of the printable area stays blank).
let right = (self.col_x(c1) + self.ci.start(c1 + 1) - self.ci.start(c1)).min(cx + w);
let bottom = (self.row_y(r1) + self.ri.start(r1 + 1) - self.ri.start(r1)).min(cy + h);
x >= cx && x < right && y >= cy && y < bottom
}
/// The header or footer section under the point: the top or bottom margin of a page, split
/// into thirds between the side margins.
pub fn hf_zone_at(&self, x: f64, y: f64) -> Option<((usize, usize), HfZone)> {
let (i, j) = self.page_at(x, y)?;
let (px, py, _, _) = self.page_rect(i, j);
let [l, r, t, b, ..] = self.margins;
let footer = if y < py + t {
false
} else if y >= py + self.page_h - b {
true
} else {
return None;
};
let inner = (self.page_w - l - r).max(3.0);
let section = (((x - px - l) / (inner / 3.0)).floor().max(0.0) as usize).min(2);
Some(((i, j), HfZone { footer, section }))
}
/// Page `(i, j)` holds printed data (others are "Click to add data" pages).
pub fn is_data_page(&self, i: usize, j: usize) -> bool {
(self.data_rows.0..=self.data_rows.1).contains(&i) && (self.data_cols.0..=self.data_cols.1).contains(&j)
}
/// Printed page number of page `(i, j)` (down, then over) and the printed page count.
pub fn page_number(&self, i: usize, j: usize) -> Option<(usize, usize)> {
if !self.is_data_page(i, j) {
return None;
}
let rows = self.data_rows.1 - self.data_rows.0 + 1;
let cols = self.data_cols.1 - self.data_cols.0 + 1;
Some(((j - self.data_cols.0) * rows + (i - self.data_rows.0) + 1, rows * cols))
}
/// Page rows and columns overlapping the layout rectangle `[x0, x1] × [y0, y1]`.
pub fn pages_in(&self, x0: f64, y0: f64, x1: f64, y1: f64) -> ((usize, usize), (usize, usize)) {
((self.page_row_at(y0), self.page_row_at(y1)), (self.page_col_at(x0), self.page_col_at(x1)))
}
/// Width / height of everything laid out so far.
pub fn extent(&self) -> (f64, f64) {
(self.page_x(self.col_bands.len()), self.page_y(self.row_bands.len()))
}
/// Rows `(first, last)` of a page row and columns of a page column.
pub fn row_band(&self, i: usize) -> Option<(u32, u32)> {
self.row_bands.get(i).copied()
}
pub fn col_band(&self, j: usize) -> Option<(u32, u32)> {
self.col_bands.get(j).copied()
}
}
/// Sheet-wide helper: the bottom-right cell a page grid for this sheet must reach so that the
/// layout rectangle up to `(x, y)` is covered. Layout coordinates never run ahead of sheet
/// coordinates (pages only add margins and gaps), so the sheet position is a safe bound.
pub fn need_for(sh: &Sheet, x: f64, y: f64) -> (u32, u32) {
(sh.row_at(y.max(0.0)).saturating_add(1).min(MAX_ROWS - 1), sh.col_at(x.max(0.0)).saturating_add(1).min(MAX_COLS - 1))
}
#[cfg(test)]
mod tests {
use gridcraft_model::{Orientation, Sheet};
use super::*;
fn wb_with(rows: u32, cols: u32) -> Workbook {
let mut wb = Workbook::new();
if let Some(sh) = wb.sheet_mut(0) {
for r in 0..rows {
for c in 0..cols {
sh.set_value(CellRef::new(r, c), gridcraft_core::Value::Number(1.0));
}
}
}
wb
}
#[test]
fn pages_match_print_pagination_and_cells_map_both_ways() {
let wb = wb_with(120, 14);
let g = PageGrid::build(&wb, 0, 400, 40).unwrap();
let printed = print::layout_sheet(&wb, 0, &PrintOptions::default(), None).unwrap();
// The printed area starts at A1, so the view's page edges are the printed ones (the
// last printed page simply ends with the data).
let starts = |b: &[(u32, u32)], n: usize| b.iter().take(n).map(|x| x.0).collect::<Vec<_>>();
assert!(printed.row_bands.len() > 1 && printed.col_bands.len() > 1);
assert_eq!(starts(&g.row_bands, printed.row_bands.len()), starts(&printed.row_bands, usize::MAX));
assert_eq!(starts(&g.col_bands, printed.col_bands.len()), starts(&printed.col_bands, usize::MAX));
assert_eq!(g.data_rows, (0, printed.row_bands.len() - 1));
assert!(g.covers(400, 40));
// Every cell maps to a point inside its own page's margins and back.
for (r, c) in [(0, 0), (5, 3), (60, 9), (119, 13), (300, 30)] {
let (x, y) = (g.col_x(c) + 1.0, g.row_y(r) + 1.0);
assert_eq!((g.row_at(y), g.col_at(x)), (r, c), "cell ({r},{c})");
assert!(g.on_cells(x, y));
let (i, j) = g.page_at(x, y).unwrap();
let (cx, cy, _, _) = g.content_rect(i, j);
assert!(x >= cx && y >= cy);
}
// The first row of the second page starts below the first page plus a gap.
let (a, _) = g.row_bands[1];
assert!((g.row_y(a) - (g.page_y(1) + g.margins[2])).abs() < 1e-6);
assert!(g.row_y(a) > g.page_h + g.gap);
}
#[test]
fn margins_gaps_and_header_zones() {
let mut wb = wb_with(10, 3);
if let Some(sh) = wb.sheet_mut(0) {
sh.print.orientation = Orientation::Landscape;
}
let g = PageGrid::build(&wb, 0, 50, 20).unwrap();
assert!(g.page_w > g.page_h, "landscape pages are wider than tall");
// A point in the top margin hits the header, thirds pick the section.
let (px, py, w, h) = g.page_rect(0, 0);
let mid_top = (px + w / 2.0, py + g.margins[2] / 2.0);
assert_eq!(g.hf_zone_at(mid_top.0, mid_top.1), Some(((0, 0), HfZone { footer: false, section: 1 })));
assert_eq!(g.hf_zone_at(px + g.margins[0] + 2.0, py + h - 2.0).map(|z| z.1), Some(HfZone { footer: true, section: 0 }));
assert_eq!(g.hf_zone_at(px + w - g.margins[1] - 2.0, py + 3.0).map(|z| z.1.section), Some(2));
assert!(!g.on_cells(mid_top.0, mid_top.1), "margins aren't cells");
// The gap between pages is no page at all.
assert_eq!(g.page_at(px + w + g.gap / 2.0, py + 10.0), None);
assert_eq!(g.hf_zone_at(px - 1.0, py + 3.0), None);
// Page numbers count printed pages only.
assert_eq!(g.page_number(0, 0), Some((1, 1)));
assert_eq!(g.page_number(0, 1), None);
assert!(g.is_data_page(0, 0) && !g.is_data_page(1, 0));
}
#[test]
fn print_scale_and_hostile_settings_stay_bounded() {
let mut wb = wb_with(3, 3);
if let Some(sh) = wb.sheet_mut(0) {
sh.print.scale = 50;
sh.print.margins = [f32::NAN, 1e9, -4.0, f32::INFINITY, 0.0, 0.0];
sh.print.row_breaks = vec![0, 1, 2, u32::MAX];
}
let g = PageGrid::build(&wb, 0, MAX_ROWS, MAX_COLS).unwrap();
assert!(g.rows_complete && g.cols_complete);
assert!(g.page_w.is_finite() && g.page_h.is_finite() && g.page_w > 0.0);
let (ex, ey) = g.extent();
assert!(ex.is_finite() && ey.is_finite());
assert_eq!(g.row_at(ey * 2.0), MAX_ROWS - 1);
assert_eq!(g.col_at(-5.0), 0);
assert_eq!(need_for(&Sheet::new("S"), f64::MAX, f64::MAX), (MAX_ROWS - 1, MAX_COLS - 1));
assert!(PageGrid::build(&wb, 7, 0, 0).is_none());
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ pub mod workbook;
pub use cell::{Cell, CellPicture, Formula};
pub use features::*;
pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, Visibility};
pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, SheetViewMode, Visibility};
pub use store::CellStore;
pub use style::{
Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Theme, Underline,
+34
View File
@@ -19,6 +19,36 @@ pub enum Visibility {
VeryHidden,
}
/// How a sheet is displayed (View › Workbook Views; `<sheetView view="…">` in XLSX).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SheetViewMode {
#[default]
Normal,
/// Pages on paper with rulers, margins and in-place headers and footers.
PageLayout,
PageBreakPreview,
}
impl SheetViewMode {
/// The XLSX / command spelling: `normal`, `pageLayout` or `pageBreakPreview`.
pub fn as_str(self) -> &'static str {
match self {
SheetViewMode::Normal => "normal",
SheetViewMode::PageLayout => "pageLayout",
SheetViewMode::PageBreakPreview => "pageBreakPreview",
}
}
pub fn parse(s: &str) -> Option<SheetViewMode> {
match s {
"normal" => Some(SheetViewMode::Normal),
"pageLayout" => Some(SheetViewMode::PageLayout),
"pageBreakPreview" => Some(SheetViewMode::PageBreakPreview),
_ => None,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Sheet {
pub name: String,
@@ -68,6 +98,9 @@ pub struct Sheet {
/// Last selection and scroll position, saved with the file.
pub view_active: CellRef,
pub view_top_left: CellRef,
/// Normal, Page Layout or Page Break Preview.
#[serde(default)]
pub view: SheetViewMode,
}
/// Default width of a column (points) and height of a row (points) for the default font.
@@ -111,6 +144,7 @@ impl Sheet {
print: PrintSettings::default(),
view_active: CellRef::default(),
view_top_left: CellRef::default(),
view: SheetViewMode::Normal,
}
}
+12 -1
View File
@@ -2,7 +2,7 @@
//!
//! `cargo run --release -p gridcraft-ui-egui --example snapshot -- [--sample sales] [--in file.xlsx]
//! [--size 1440x900] [--scale 2] [--cmd 'id={json}']... [--tab Insert] [--dark] [--language en|zh|ja|ko|ru|pt]
//! [--dialog 'name={json}'] out.png`
//! [--dialog 'name={json}'] [--hf-edit header|footer] out.png`
use gridcraft_engine::Session;
use gridcraft_ui_egui::SheetApp;
@@ -21,6 +21,7 @@ fn main() {
let mut chart: Option<u32> = None;
let mut dialog: Option<String> = None;
let mut language: Option<String> = None;
let mut hf_edit: Option<String> = None;
let mut out = "snapshot.png".to_string();
let mut i = 0;
while i < args.len() {
@@ -66,6 +67,10 @@ fn main() {
i += 1;
dialog = args.get(i).cloned();
}
"--hf-edit" => {
i += 1;
hf_edit = args.get(i).cloned();
}
"--language" => {
i += 1;
language = args.get(i).cloned();
@@ -113,6 +118,12 @@ fn main() {
d.split_once('=').map(|(a, b)| (a, serde_json::from_str(b).unwrap_or(json!({})))).unwrap_or((d.as_str(), json!({})));
app.open_dialog(name, p);
}
// Page Layout view: edit the first page's centre header (or footer) section.
let active = app.session.active().and_then(|d| d.wb.active().cloned().map(|s| (d.wb.active_sheet, s)));
if let (Some(part), Some((si, sh))) = (&hf_edit, active) {
let zone = gridcraft_engine::paper::HfZone { footer: part == "footer", section: 1 };
gridcraft_ui_egui::paper_view::begin(&mut app, si, &sh, (0, 0), zone);
}
app
},
);
+171 -49
View File
@@ -84,6 +84,12 @@ pub struct GridState {
/// Page ranges for Page Break Preview, cached per (doc uid, revision, sheet).
pub pages: Option<((u64, u64, usize), Vec<(RangeRef, u32)>)>,
pub header_menu: Option<(Pos2, bool)>,
/// Page Layout view pagination, cached per (doc uid, revision, sheet, rows, columns).
pub paper: Option<crate::paper_view::PaperCache>,
/// Header or footer being edited in place (Page Layout view).
pub hf_edit: Option<crate::paper_view::HfEdit>,
/// The grid geometry of the last frame (hit-testing for agents and tests).
pub geo: Option<Geo>,
}
impl GridState {
@@ -111,6 +117,8 @@ pub struct Geo {
/// Prefix sums over custom row heights / column widths, built once per frame.
ri: std::sync::Arc<LineIndex>,
ci: std::sync::Arc<LineIndex>,
/// Page Layout view: cells sit on pages (`scroll` is then in page-layout units).
pub paper: Option<Arc<gridcraft_engine::paper::PageGrid>>,
}
impl Geo {
@@ -127,9 +135,42 @@ impl Geo {
let frozen_w = (ci.start(fc) as f32) * z;
let frozen_h = (ri.start(fr) as f32) * z;
let cells = Rect::from_min_max(pos2(rect.left() + header_w, rect.top() + header_h), rect.max);
Geo { rect, cells, z, header_w, header_h, fr, fc, frozen_w, frozen_h, scroll, ri, ci }
Geo { rect, cells, z, header_w, header_h, fr, fc, frozen_w, frozen_h, scroll, ri, ci, paper: None }
}
/// Page Layout view geometry: `rect` excludes the rulers; cells are drawn at the print scale
/// on pages laid out by `grid`; frozen panes don't apply (as in Excel).
pub fn paged(sh: &Sheet, rect: Rect, scroll: egui::Vec2, grid: Arc<gridcraft_engine::paper::PageGrid>) -> Geo {
let mut g = Geo::new(sh, rect, scroll);
let zoom = (sh.zoom as f32 / 100.0).clamp(0.1, 4.0);
g.z = zoom * DISPLAY_SCALE * grid.scale.clamp(0.1, 4.0);
(g.fr, g.fc, g.frozen_w, g.frozen_h) = (0, 0, 0.0, 0.0);
g.paper = Some(grid);
g
}
/// The point is over cells (in Page Layout view: on a page, inside its margins).
pub fn in_cells(&self, p: Pos2) -> bool {
if !self.cells.contains(p) {
return false;
}
match &self.paper {
Some(pg) => pg.on_cells(
self.scroll.x as f64 + ((p.x - self.cells.left()) / self.z) as f64,
self.scroll.y as f64 + ((p.y - self.cells.top()) / self.z) as f64,
),
None => true,
}
}
/// Scroll offset (in `scroll` units) at which `row` reaches the top of the scrolled area.
pub fn scroll_top_of(&self, sh: &Sheet, row: u32) -> f32 {
match &self.paper {
Some(pg) => pg.row_y(row) as f32,
None => (sh.row_top(row) - sh.row_top(self.fr)) as f32,
}
}
pub fn x(&self, _sh: &Sheet, col: u32) -> f32 {
if let Some(pg) = &self.paper {
return self.cells.left() + (pg.col_x(col) - self.scroll.x as f64) as f32 * self.z;
}
if col < self.fc {
self.cells.left() + self.ci.start(col) as f32 * self.z
} else {
@@ -137,6 +178,9 @@ impl Geo {
}
}
pub fn y(&self, _sh: &Sheet, row: u32) -> f32 {
if let Some(pg) = &self.paper {
return self.cells.top() + (pg.row_y(row) - self.scroll.y as f64) as f32 * self.z;
}
if row < self.fr {
self.cells.top() + self.ri.start(row) as f32 * self.z
} else {
@@ -155,6 +199,9 @@ impl Geo {
Rect::from_min_max(a.min, b.max)
}
pub fn col_at(&self, _sh: &Sheet, x: f32) -> u32 {
if let Some(pg) = &self.paper {
return pg.col_at(self.scroll.x as f64 + ((x - self.cells.left()) / self.z) as f64);
}
let dx = (x - self.cells.left()) / self.z;
if dx < self.frozen_w / self.z {
self.ci.at(dx.max(0.0) as f64)
@@ -163,6 +210,9 @@ impl Geo {
}
}
pub fn row_at(&self, _sh: &Sheet, y: f32) -> u32 {
if let Some(pg) = &self.paper {
return pg.row_at(self.scroll.y as f64 + ((y - self.cells.top()) / self.z) as f64);
}
let dy = (y - self.cells.top()) / self.z;
if dy < self.frozen_h / self.z {
self.ri.at(dy.max(0.0) as f64)
@@ -322,6 +372,19 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
let Some(sh) = wb.sheet(si) else { return };
let sel = d.selection.clone();
let mut view = app.view();
// Page Layout view: pages on a desk, rulers above and left of the headings.
let paper = sh.view == gridcraft_engine::model::SheetViewMode::PageLayout;
let ruler = if paper && !app.session.hide_ruler { crate::paper_view::RULER } else { 0.0 };
let grid_rect = Rect::from_min_max(pos2((rect.left() + ruler).min(rect.right()), (rect.top() + ruler).min(rect.bottom())), rect.max);
let make_geo = |app: &mut SheetApp, scroll: egui::Vec2| -> Geo {
if paper {
let probe = Geo::new(sh, grid_rect, scroll);
if let Some(g) = crate::paper_view::grid_for(app, &wb, si, sh, probe.cells.size(), scroll) {
return Geo::paged(sh, grid_rect, scroll, g);
}
}
Geo::new(sh, rect, scroll)
};
// Scrolling.
let (scroll_delta, zoom_delta, modifiers) = ui.input(|i| (i.smooth_scroll_delta, i.zoom_delta(), i.modifiers));
if (resp.hovered() || resp.contains_pointer()) && !crate::text_box::contains_pointer(app, ui.ctx()) {
@@ -329,7 +392,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
let z = (sh.zoom as f32 * zoom_delta).clamp(10.0, 400.0).round();
let _ = app.session.run("view.zoom", json!({"percent": z}));
} else if scroll_delta != egui::Vec2::ZERO {
let z = sh.zoom as f32 / 100.0 * DISPLAY_SCALE;
let z = if paper { make_geo(app, view.scroll).z } else { sh.zoom as f32 / 100.0 * DISPLAY_SCALE };
let mut d = scroll_delta;
if modifiers.shift && d.x == 0.0 {
d = vec2(d.y, 0.0);
@@ -338,15 +401,28 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
}
}
view.scroll = view.scroll.max(egui::Vec2::ZERO);
let max_y = sh.row_top(MAX_ROWS - 40) as f32;
let max_x = sh.col_left(MAX_COLS - 20) as f32;
let (mut max_x, mut max_y) = (sh.col_left(MAX_COLS - 20) as f32, sh.row_top(MAX_ROWS - 40) as f32);
if paper {
// Page layout units run past sheet units (margins and gaps); the end is known once the
// pages reach the last row / column.
let g = make_geo(app, view.scroll);
if let Some(pg) = &g.paper {
let (ex, ey) = pg.extent();
max_x = if pg.cols_complete { (ex as f32 - g.cells.width() / g.z).max(0.0) } else { 1.0e9 };
max_y = if pg.rows_complete { (ey as f32 - g.cells.height() / g.z).max(0.0) } else { 1.0e9 };
}
}
view.scroll.x = view.scroll.x.min(max_x);
view.scroll.y = view.scroll.y.min(max_y);
let mut geo = Geo::new(sh, rect, view.scroll);
let mut geo = make_geo(app, view.scroll);
// Keep the active cell visible after keyboard moves.
if app.grid.ensure_visible {
app.grid.ensure_visible = false;
let target = app.editor.as_ref().and_then(|e| e.point_cell).unwrap_or(sel.active);
if paper {
crate::paper_view::ensure_covers(app, &wb, si, target);
geo = make_geo(app, view.scroll);
}
let cr = geo.cell_rect(sh, target);
let area = Rect::from_min_max(pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h), geo.cells.max);
if target.row >= geo.fr {
@@ -364,16 +440,70 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
}
}
view.scroll = view.scroll.max(egui::Vec2::ZERO);
geo = Geo::new(sh, rect, view.scroll);
geo = make_geo(app, view.scroll);
}
// Snap scrolling to whole rows/columns at rest like Excel? Keep smooth (better on trackpads).
if let Some(v) = app.view_mut() {
*v = view;
}
app.grid.cells_rect = Some(geo.cells);
app.grid.geo = Some(geo.clone());
let painter = ui.painter_at(rect);
painter.rect_filled(rect, 0.0, t.grid_bg);
let mut cf = CfCache::new();
let quads = if geo.paper.is_some() {
crate::paper_view::paint_pages(app, &painter, &geo, &wb, si, sh, &sel, &mut cf, &t)
} else {
paint_panes(app, &painter, &geo, &wb, si, sh, &sel, &mut cf, &t)
};
// Objects above cells.
crate::chartview::paint_objects(app, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh);
paint_overlays(app, ui, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh, &sel);
if sh.view == gridcraft_engine::model::SheetViewMode::PageBreakPreview {
paint_pages(app, &painter.with_clip_rect(geo.cells), &geo, sh);
}
if geo.paper.is_some() {
crate::paper_view::paint_headers_footers(app, ui, &painter, &geo, &wb, sh);
}
// Headers.
if sh.show_headings {
paint_headers(&painter, &geo, sh, &sel, &t, &quads);
}
if geo.paper.is_some() {
crate::paper_view::paint_rulers(app, &painter, rect, &geo, &t);
}
// Interaction.
let previous_popups = (app.grid.filter_menu, app.grid.list_picker, app.grid.header_menu);
crate::paper_view::interact(app, ui, &resp, &geo, sh, &wb);
interact(app, ui, &resp, &geo, sh, &wb);
crate::text_box::show(app, ui, &geo, sh);
if app.editor.as_ref().is_some_and(|e| !e.from_formula_bar) {
in_cell_editor(app, ui, &geo, sh, &wb);
}
filter_menu(app, ui, &geo, previous_popups.0 == app.grid.filter_menu);
list_picker(app, ui, &geo, previous_popups.1 == app.grid.list_picker);
header_menu(app, ui, previous_popups.2 == app.grid.header_menu);
context_menu(app, ui);
vertical_scrollbar(app, ui, sh, &geo, available.right(), max_y, &t);
if app.session.clipboard.is_some() {
app.grid.marching_phase = (app.grid.marching_phase + 0.5) % 8.0;
ui.ctx().request_repaint_after(std::time::Duration::from_millis(60));
}
}
/// Normal view: up to four panes (frozen rows/columns), each painted with its own clip.
#[allow(clippy::too_many_arguments)]
fn paint_panes(
app: &SheetApp,
painter: &Painter,
geo: &Geo,
wb: &Workbook,
si: usize,
sh: &Sheet,
sel: &gridcraft_engine::Selection,
cf: &mut CfCache,
t: &Tokens,
) -> Vec<((u32, u32), (u32, u32), Rect)> {
// Quadrants: (rows, cols, clip).
let (sr0, sr1) = geo.scroll_rows(sh);
let (sc0, sc1) = geo.scroll_cols(sh);
@@ -395,8 +525,8 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
continue;
}
let p = painter.with_clip_rect(*clip);
paint_quadrant(&p, &geo, &wb, si, sh, *rows, *cols, &mut cf, &t);
paint_selection(&p, &geo, sh, &sel, &t, app);
paint_quadrant(&p, geo, wb, si, sh, *rows, *cols, cf, t);
paint_selection(&p, geo, sh, sel, t, app);
}
// Freeze lines.
if geo.fr > 0 {
@@ -405,32 +535,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
if geo.fc > 0 {
painter.line_segment([pos2(split.x, geo.cells.top()), pos2(split.x, geo.cells.bottom())], Stroke::new(1.0, Color32::from_gray(150)));
}
// Objects above cells.
crate::chartview::paint_objects(app, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh);
paint_overlays(app, ui, &painter.with_clip_rect(geo.cells), &geo, &wb, si, sh, &sel);
if app.session.view_mode == "pageBreakPreview" || app.session.view_mode == "pageLayout" {
paint_pages(app, &painter.with_clip_rect(geo.cells), &geo, sh);
}
// Headers.
if sh.show_headings {
paint_headers(&painter, &geo, sh, &sel, &t, &quads);
}
// Interaction.
let previous_popups = (app.grid.filter_menu, app.grid.list_picker, app.grid.header_menu);
interact(app, ui, &resp, &geo, sh, &wb);
crate::text_box::show(app, ui, &geo, sh);
if app.editor.as_ref().is_some_and(|e| !e.from_formula_bar) {
in_cell_editor(app, ui, &geo, sh, &wb);
}
filter_menu(app, ui, &geo, previous_popups.0 == app.grid.filter_menu);
list_picker(app, ui, &geo, previous_popups.1 == app.grid.list_picker);
header_menu(app, ui, previous_popups.2 == app.grid.header_menu);
context_menu(app, ui);
vertical_scrollbar(app, ui, sh, &geo, available.right(), max_y, &t);
if app.session.clipboard.is_some() {
app.grid.marching_phase = (app.grid.marching_phase + 0.5) % 8.0;
ui.ctx().request_repaint_after(std::time::Duration::from_millis(60));
}
quads
}
/// The worksheet is painted virtually, so it needs its own scrollbar rather than a
@@ -443,7 +548,7 @@ fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &G
let visible = track.height() / geo.z;
let scroll = app.view().scroll.y;
let last = sh.used_range().map(|r| r.end.row.saturating_add(10)).unwrap_or(100).clamp(100, MAX_ROWS - 1);
let content = (sh.row_top(last) - sh.row_top(geo.fr)) as f32;
let content = geo.scroll_top_of(sh, last);
let extent = (content - visible).max(scroll).max(0.0).min(limit);
let id = ui.id().with("vscroll");
let resp = ui.interact(track, id, Sense::click_and_drag());
@@ -483,7 +588,17 @@ fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &G
}
#[allow(clippy::too_many_arguments)]
fn paint_quadrant(p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet, rows: (u32, u32), cols: (u32, u32), cf: &mut CfCache, t: &Tokens) {
pub(crate) fn paint_quadrant(
p: &Painter,
geo: &Geo,
wb: &Workbook,
si: usize,
sh: &Sheet,
rows: (u32, u32),
cols: (u32, u32),
cf: &mut CfCache,
t: &Tokens,
) {
let z = geo.z;
let (r0, r1) = rows;
let (c0, c1) = cols;
@@ -901,7 +1016,7 @@ fn paint_cf_icon(p: &Painter, r: Rect, set: &str, idx: usize) {
}
}
fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, t: &Tokens, app: &SheetApp) {
pub(crate) fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, t: &Tokens, app: &SheetApp) {
// Fill all areas except the active cell.
for r in &sel.ranges {
if r.is_single() && sel.ranges.len() == 1 {
@@ -973,7 +1088,14 @@ fn paint_selection(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::S
}
}
fn paint_headers(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Selection, t: &Tokens, quads: &[((u32, u32), (u32, u32), Rect)]) {
pub(crate) fn paint_headers(
p: &Painter,
geo: &Geo,
sh: &Sheet,
sel: &gridcraft_engine::Selection,
t: &Tokens,
quads: &[((u32, u32), (u32, u32), Rect)],
) {
let font = theme::ui_font((11.5 * (geo.z / DISPLAY_SCALE).max(0.75)).min(16.0));
let r = geo.rect;
let top = Rect::from_min_max(pos2(geo.cells.left(), r.top()), pos2(r.right(), geo.cells.top()));
@@ -1049,7 +1171,7 @@ fn paint_headers(p: &Painter, geo: &Geo, sh: &Sheet, sel: &gridcraft_engine::Sel
fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet, wb: &Workbook) {
// Text selection and editing must not also move the object or edit worksheet cells.
if app.text_box_editor.is_some() {
if app.text_box_editor.is_some() || app.grid.hf_edit.is_some() {
return;
}
let ctx = ui.ctx().clone();
@@ -1058,7 +1180,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
let pointer = ctx.input(|i| i.pointer.clone());
let mods = ctx.input(|i| i.modifiers);
let pos = pointer.interact_pos().or(pointer.hover_pos());
let in_cells = pos.is_some_and(|p| geo.cells.contains(p));
let in_cells = pos.is_some_and(|p| geo.in_cells(p));
let in_col_header = pos.is_some_and(|p| p.y < geo.cells.top() && p.y >= geo.rect.top() && p.x >= geo.cells.left());
let in_row_header = pos.is_some_and(|p| p.x < geo.cells.left() && p.x >= geo.rect.left() && p.y >= geo.cells.top());
let in_corner = pos.is_some_and(|p| p.x < geo.cells.left() && p.y < geo.cells.top() && geo.rect.contains(p));
@@ -1098,8 +1220,10 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
if let Some(p) = pos
&& resp.dragged()
{
let sx = sh.col_left(geo.fc) as f32 + geo.scroll.x + (p.x - geo.cells.left() - geo.frozen_w) / geo.z;
let sy = sh.row_top(geo.fr) as f32 + geo.scroll.y + (p.y - geo.cells.top() - geo.frozen_h) / geo.z;
// Through the cell under the pointer, so frozen panes and pages map right.
let c = geo.cell_at(sh, p);
let sx = sh.col_left(c.col) as f32 + (p.x - geo.x(sh, c.col)) / geo.z;
let sy = sh.row_top(c.row) as f32 + (p.y - geo.y(sh, c.row)) / geo.z;
app.grid.ink.push([sx, sy]);
}
if !app.grid.ink.is_empty() {
@@ -1109,10 +1233,8 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
.ink
.iter()
.map(|q| {
pos2(
geo.cells.left() + geo.frozen_w + (q[0] - sh.col_left(geo.fc) as f32 - geo.scroll.x) * geo.z,
geo.cells.top() + geo.frozen_h + (q[1] - sh.row_top(geo.fr) as f32 - geo.scroll.y) * geo.z,
)
let (col, row) = (sh.col_at(q[0].max(0.0) as f64), sh.row_at(q[1].max(0.0) as f64));
pos2(geo.x(sh, col) + (q[0] - sh.col_left(col) as f32) * geo.z, geo.y(sh, row) + (q[1] - sh.row_top(row) as f32) * geo.z)
})
.collect();
ui.painter().add(egui::epaint::PathShape::line(pts, egui::epaint::PathStroke::new(2.0 * geo.z, Color32::from_rgb(0x1F, 0x5F, 0xC9))));
@@ -1571,7 +1693,7 @@ fn on_border(r: Rect, p: Pos2) -> bool {
fn col_edge(geo: &Geo, sh: &Sheet, x: f32) -> Option<(u32, f32)> {
let c = geo.col_at(sh, x);
for cc in [c, c.saturating_sub(1)] {
let edge = geo.x(sh, cc + 1);
let edge = geo.x(sh, cc) + sh.col_width(cc) * geo.z;
if (x - edge).abs() <= 4.0 {
return Some((cc, edge));
}
@@ -1582,7 +1704,7 @@ fn col_edge(geo: &Geo, sh: &Sheet, x: f32) -> Option<(u32, f32)> {
fn row_edge(geo: &Geo, sh: &Sheet, y: f32) -> Option<(u32, f32)> {
let r = geo.row_at(sh, y);
for rr in [r, r.saturating_sub(1)] {
let edge = geo.y(sh, rr + 1);
let edge = geo.y(sh, rr) + sh.row_height(rr) * geo.z;
if (y - edge).abs() <= 3.0 {
return Some((rr, edge));
}
@@ -1963,7 +2085,7 @@ pub fn validation_arrow(sh: &Sheet, geo: &Geo, c: CellRef) -> Option<Rect> {
}
#[allow(clippy::too_many_arguments)]
fn paint_overlays(
pub(crate) fn paint_overlays(
app: &mut SheetApp,
ui: &egui::Ui,
p: &Painter,
+26
View File
@@ -416,6 +416,32 @@ const JAPANESE: &[(&str, &str)] = &[
("More\nFunctions", "その他の関数"),
("Sheet Options|View", "画面"),
("Print", "印刷"),
// Page Layout view and the Header & Footer tab.
("Click to add data", "クリックしてデータを追加"),
("Add header", "ヘッダーの追加"),
("Add footer", "フッターの追加"),
("(none)", "(なし)"),
("Header", "ヘッダー"),
("Footer", "フッター"),
("Page\nNumber", "ページ\n番号"),
("Number\nof Pages", "ページ数"),
("Current\nDate", "現在の\n日付"),
("Current\nTime", "現在の\n時刻"),
("File\nPath", "ファイルの\nパス"),
("File\nName", "ファイル名"),
("Sheet\nName", "シート名"),
("Go to\nHeader", "ヘッダーに\n移動"),
("Go to\nFooter", "フッターに\n移動"),
("Page Number", "ページ番号"),
("Number of Pages", "ページ数"),
("Current Date", "現在の日付"),
("Current Time", "現在の時刻"),
("File Path", "ファイルのパス"),
("File Name", "ファイル名"),
("Sheet Name", "シート名"),
("Go to Header", "ヘッダーに移動"),
("Go to Footer", "フッターに移動"),
("Ruler", "ルーラー"),
];
#[cfg(test)]
+26
View File
@@ -268,6 +268,32 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Dark" => "어둡게",
"Sheet Options|View" => "화면",
"Print" => "인쇄",
// Page Layout view and the Header & Footer tab.
"Click to add data" => "클릭하여 데이터 추가",
"Add header" => "머리글 추가",
"Add footer" => "바닥글 추가",
"(none)" => "(없음)",
"Header" => "머리글",
"Footer" => "바닥글",
"Page\nNumber" => "페이지\n번호",
"Number\nof Pages" => "페이지 수",
"Current\nDate" => "현재\n날짜",
"Current\nTime" => "현재\n시간",
"File\nPath" => "파일\n경로",
"File\nName" => "파일\n이름",
"Sheet\nName" => "시트\n이름",
"Go to\nHeader" => "머리글로\n이동",
"Go to\nFooter" => "바닥글로\n이동",
"Page Number" => "페이지 번호",
"Number of Pages" => "페이지 수",
"Current Date" => "현재 날짜",
"Current Time" => "현재 시간",
"File Path" => "파일 경로",
"File Name" => "파일 이름",
"Sheet Name" => "시트 이름",
"Go to Header" => "머리글로 이동",
"Go to Footer" => "바닥글로 이동",
"Ruler" => "눈금자",
_ => return None,
})
}
+26
View File
@@ -269,6 +269,32 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Dark" => "Escuro",
"Sheet Options|View" => "Exibir",
"Print" => "Imprimir",
// Page Layout view and the Header & Footer tab.
"Click to add data" => "Clique para adicionar dados",
"Add header" => "Adicionar cabeçalho",
"Add footer" => "Adicionar rodapé",
"(none)" => "(nenhum)",
"Header" => "Cabeçalho",
"Footer" => "Rodapé",
"Page\nNumber" => "Número\nda Página",
"Number\nof Pages" => "Número\nde Páginas",
"Current\nDate" => "Data\nAtual",
"Current\nTime" => "Hora\nAtual",
"File\nPath" => "Caminho\ndo Arquivo",
"File\nName" => "Nome do\nArquivo",
"Sheet\nName" => "Nome da\nPlanilha",
"Go to\nHeader" => "Ir para o\nCabeçalho",
"Go to\nFooter" => "Ir para o\nRodapé",
"Page Number" => "Número da Página",
"Number of Pages" => "Número de Páginas",
"Current Date" => "Data Atual",
"Current Time" => "Hora Atual",
"File Path" => "Caminho do Arquivo",
"File Name" => "Nome do Arquivo",
"Sheet Name" => "Nome da Planilha",
"Go to Header" => "Ir para o Cabeçalho",
"Go to Footer" => "Ir para o Rodapé",
"Ruler" => "Régua",
_ => return None,
})
}
+26
View File
@@ -268,6 +268,32 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Dark" => "Тёмная",
"Sheet Options|View" => "На экране",
"Print" => "Печать",
// Page Layout view and the Header & Footer tab.
"Click to add data" => "Щелкните, чтобы добавить данные",
"Add header" => "Добавить верхний колонтитул",
"Add footer" => "Добавить нижний колонтитул",
"(none)" => "(нет)",
"Header" => "Верхний колонтитул",
"Footer" => "Нижний колонтитул",
"Page\nNumber" => "Номер\nстраницы",
"Number\nof Pages" => "Число\nстраниц",
"Current\nDate" => "Текущая\nдата",
"Current\nTime" => "Текущее\nвремя",
"File\nPath" => "Путь\nк файлу",
"File\nName" => "Имя\nфайла",
"Sheet\nName" => "Имя\nлиста",
"Go to\nHeader" => "Перейти к верхнему\nколонтитулу",
"Go to\nFooter" => "Перейти к нижнему\nколонтитулу",
"Page Number" => "Номер страницы",
"Number of Pages" => "Число страниц",
"Current Date" => "Текущая дата",
"Current Time" => "Текущее время",
"File Path" => "Путь к файлу",
"File Name" => "Имя файла",
"Sheet Name" => "Имя листа",
"Go to Header" => "Перейти к верхнему колонтитулу",
"Go to Footer" => "Перейти к нижнему колонтитулу",
"Ruler" => "Линейка",
_ => return None,
})
}
+26
View File
@@ -268,6 +268,32 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Dark" => "深色",
"Sheet Options|View" => "屏幕",
"Print" => "打印",
// Page Layout view and the Header & Footer tab.
"Click to add data" => "单击可添加数据",
"Add header" => "添加页眉",
"Add footer" => "添加页脚",
"(none)" => "(无)",
"Header" => "页眉",
"Footer" => "页脚",
"Page\nNumber" => "页码",
"Number\nof Pages" => "页数",
"Current\nDate" => "当前\n日期",
"Current\nTime" => "当前\n时间",
"File\nPath" => "文件\n路径",
"File\nName" => "文件名",
"Sheet\nName" => "工作表名",
"Go to\nHeader" => "转至\n页眉",
"Go to\nFooter" => "转至\n页脚",
"Page Number" => "页码",
"Number of Pages" => "页数",
"Current Date" => "当前日期",
"Current Time" => "当前时间",
"File Path" => "文件路径",
"File Name" => "文件名",
"Sheet Name" => "工作表名",
"Go to Header" => "转至页眉",
"Go to Footer" => "转至页脚",
"Ruler" => "标尺",
_ => return None,
})
}
+46
View File
@@ -111,6 +111,14 @@ pub enum Icon {
Sheet,
Book,
Folder,
/// Header & Footer: a page with its top / bottom band marked.
Header,
Footer,
PageNumber,
PageCount,
Calendar,
Clock,
FileName,
}
struct G<'a> {
@@ -605,6 +613,44 @@ 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::Header | Icon::Footer => {
g.rect(3.5, 1.5, 9.0, 13.0, None, Some(c));
let y = if icon == Icon::Header { 2.5 } else { 11.0 };
g.rect(4.5, y, 7.0, 2.5, Some(BLUE), None);
for yy in if icon == Icon::Header { [7.0, 9.5] } else { [4.5, 7.0] } {
g.stroke(&[(5.5, yy), (10.5, yy)], light);
}
}
Icon::PageNumber | Icon::PageCount => {
if icon == Icon::PageCount {
g.rect(5.5, 1.0, 8.5, 11.5, None, Some(light));
}
g.rect(2.5, 3.0, 8.5, 12.0, Some(Color32::WHITE), Some(c));
// A "#" mark.
g.stroke(&[(5.3, 6.5), (4.8, 12.5)], BLUE);
g.stroke(&[(8.3, 6.5), (7.8, 12.5)], BLUE);
g.stroke(&[(4.0, 8.4), (9.6, 8.4)], BLUE);
g.stroke(&[(3.8, 10.8), (9.4, 10.8)], BLUE);
}
Icon::Calendar => {
g.rect(2.0, 3.0, 12.0, 11.0, Some(Color32::WHITE), Some(c));
g.rect(2.0, 3.0, 12.0, 3.0, Some(BLUE), None);
for (x, y) in [(4.5, 8.5), (7.5, 8.5), (10.5, 8.5), (4.5, 11.5), (7.5, 11.5)] {
g.rect(x - 0.6, y - 0.6, 1.4, 1.4, Some(c), None);
}
}
Icon::Clock => {
let center = g.pt(8.0, 8.0);
g.p.circle_stroke(center, 6.0 * g.s, Stroke::new(1.15 * g.s.clamp(1.0, 2.0), c));
g.stroke(&[(8.0, 4.5), (8.0, 8.0), (10.8, 9.6)], BLUE);
}
Icon::FileName => {
g.poly(&[(3.0, 1.5), (10.0, 1.5), (13.0, 4.5), (13.0, 14.5), (3.0, 14.5)], Color32::WHITE);
g.line(&[(3.0, 1.5), (10.0, 1.5), (13.0, 4.5), (13.0, 14.5), (3.0, 14.5), (3.0, 1.5)], c, 1.15);
g.stroke(&[(10.0, 1.5), (10.0, 4.5), (13.0, 4.5)], c);
g.line(&[(5.0, 9.0), (11.0, 9.0)], BLUE, 1.4);
g.stroke(&[(5.0, 11.5), (9.0, 11.5)], light);
}
}
}
+1
View File
@@ -20,6 +20,7 @@ pub mod i18n;
pub mod icons;
pub mod keytips;
pub mod panes;
pub mod paper_view;
pub mod pivot_pane;
pub mod ribbon;
pub mod tabs;
+605
View File
@@ -0,0 +1,605 @@
//! Page Layout view (View › Workbook Views › Page Layout): the sheet drawn as printed pages on a
//! desk, with rulers, margins, headers and footers.
//!
//! Pagination and the cell ↔ page mapping come from [`gridcraft_engine::paper::PageGrid`] (the
//! print pagination, so pages here are the printed pages). [`crate::grid::Geo`] carries the grid,
//! so painting, hit-testing, selection and editing work as in Normal view. Headers and footers are
//! edited in place: clicking a page's top or bottom margin edits that section, and the edit is
//! committed through `pageLayout.headerFooter` (one undo step) when the user clicks elsewhere.
use std::sync::Arc;
use egui::{Align2, Color32, FontId, Key, Painter, Pos2, Rect, Stroke, StrokeKind, TextEdit, pos2, vec2};
use gridcraft_engine::cf::CfCache;
use gridcraft_engine::cmd::print::{self, HfContext};
use gridcraft_engine::model::{Sheet, SheetViewMode, Workbook};
use gridcraft_engine::paper::{self, HfZone, PageGrid};
use serde_json::json;
use crate::SheetApp;
use crate::grid::{self, DISPLAY_SCALE, Geo};
use crate::icons::Icon;
use crate::theme::{self, Tokens};
/// Ruler thickness in points.
pub const RULER: f32 = 18.0;
/// Pages whose cells are painted in one frame; further visible pages only get their paper.
const MAX_PAGES_PAINTED: usize = 96;
/// Page frames considered per frame (tiny zooms show many pages).
const MAX_PAGE_FRAMES: usize = 2_000;
/// The contextual ribbon tab shown while a header or footer is edited.
pub const TAB: &str = "Header & Footer";
/// Cached pagination: (doc uid, revision, sheet, sheet identity) → grid.
pub type PaperCache = ((u64, u64, usize, usize), Arc<PageGrid>);
/// A header or footer being edited in place.
#[derive(Clone, Debug)]
pub struct HfEdit {
pub sheet: usize,
/// Page (row band, column band) whose header/footer is shown in edit.
pub page: (usize, usize),
pub footer: bool,
/// 0 left, 1 centre, 2 right.
pub section: usize,
/// The three sections as the user sees them (`&[Page]` field names).
pub texts: [String; 3],
original: [String; 3],
request_focus: bool,
/// Caret (characters) of the active section, kept while the ribbon has focus.
caret: usize,
set_caret: Option<usize>,
prev_tab: String,
}
/// Rulers measure in inches for English, centimetres otherwise.
fn metric(app: &SheetApp) -> bool {
!matches!(app.ui.language, crate::i18n::Language::En)
}
fn cache_key(app: &SheetApp, wb: &Workbook, si: usize) -> Option<(u64, u64, usize, usize)> {
let d = app.session.active()?;
Some((d.uid, d.revision, si, wb.sheets.get(si).map_or(0, |a| Arc::as_ptr(a) as usize)))
}
/// Paginates at least as far as `cell` (before scrolling to it).
pub fn ensure_covers(app: &mut SheetApp, wb: &Workbook, si: usize, cell: gridcraft_engine::core::CellRef) {
let Some(key) = cache_key(app, wb, si) else { return };
let (rows, cols) = match app.grid.paper.as_ref().filter(|(k, _)| *k == key) {
Some((_, g)) if g.covers(cell.row, cell.col) => return,
Some((_, g)) => (g.row_bands.last().map_or(0, |b| b.1), g.col_bands.last().map_or(0, |b| b.1)),
None => (0, 0),
};
if let Some(g) = PageGrid::build(wb, si, rows.max(cell.row).saturating_add(64), cols.max(cell.col).saturating_add(8)) {
app.grid.paper = Some((key, Arc::new(g)));
}
}
/// The page grid for the active sheet, covering what the view shows (cached).
pub fn grid_for(app: &mut SheetApp, wb: &Workbook, si: usize, sh: &Sheet, view: egui::Vec2, scroll: egui::Vec2) -> Option<Arc<PageGrid>> {
let key = cache_key(app, wb, si)?;
let cached = app.grid.paper.as_ref().filter(|(k, _)| *k == key).map(|(_, g)| g.clone());
let base = match cached {
Some(g) => g,
None => Arc::new(PageGrid::build(wb, si, 0, 0)?),
};
let z = ((sh.zoom as f32 / 100.0).clamp(0.1, 4.0) * DISPLAY_SCALE * base.scale.clamp(0.1, 4.0)).max(0.001);
let (x1, y1) = (scroll.x as f64 + (view.x / z) as f64, scroll.y as f64 + (view.y / z) as f64);
let (need_r, need_c) = paper::need_for(sh, x1, y1);
let g = if base.covers(need_r, need_c) {
base
} else {
// Grow in steps so scrolling doesn't re-paginate every frame.
Arc::new(PageGrid::build(wb, si, need_r.saturating_add(511) / 512 * 512, need_c.saturating_add(31) / 32 * 32)?)
};
app.grid.paper = Some((key, g.clone()));
Some(g)
}
/// Layout point → screen.
fn screen(geo: &Geo, x: f64, y: f64) -> Pos2 {
pos2(geo.cells.left() + (x - geo.scroll.x as f64) as f32 * geo.z, geo.cells.top() + (y - geo.scroll.y as f64) as f32 * geo.z)
}
fn screen_rect(geo: &Geo, (x, y, w, h): (f64, f64, f64, f64)) -> Rect {
Rect::from_min_max(screen(geo, x, y), screen(geo, x + w, y + h))
}
/// Visible pages (row band range, column band range).
fn visible_pages(geo: &Geo, pg: &PageGrid) -> ((usize, usize), (usize, usize)) {
let (x0, y0) = (geo.scroll.x as f64, geo.scroll.y as f64);
let (x1, y1) = (x0 + (geo.cells.width() / geo.z) as f64, y0 + (geo.cells.height() / geo.z) as f64);
pg.pages_in(x0, y0, x1, y1)
}
fn pages_iter(geo: &Geo, pg: &PageGrid) -> impl Iterator<Item = (usize, usize)> {
let ((i0, i1), (j0, j1)) = visible_pages(geo, pg);
(j0..=j1).flat_map(move |j| (i0..=i1).map(move |i| (i, j))).take(MAX_PAGE_FRAMES)
}
/// Paints the desk, the pages and their cells; returns the per-page quadrants for the headings.
#[allow(clippy::too_many_arguments)]
pub(crate) fn paint_pages(
app: &SheetApp,
p: &Painter,
geo: &Geo,
wb: &Workbook,
si: usize,
sh: &Sheet,
sel: &gridcraft_engine::Selection,
cf: &mut CfCache,
t: &Tokens,
) -> Vec<((u32, u32), (u32, u32), Rect)> {
let mut quads = Vec::new();
let Some(pg) = geo.paper.clone() else { return quads };
p.rect_filled(geo.cells, 0.0, t.desk);
let cells = p.with_clip_rect(geo.cells);
let mut painted = 0usize;
for (i, j) in pages_iter(geo, &pg) {
let page = screen_rect(geo, pg.page_rect(i, j));
if !page.intersects(geo.cells) {
continue;
}
cells.rect_filled(page.translate(vec2(1.5, 2.0)), 0.0, t.shadow);
cells.rect_filled(page, 0.0, t.grid_bg);
let (Some((r0, r1)), Some((c0, c1))) = (pg.row_band(i), pg.col_band(j)) else { continue };
let clip = screen_rect(geo, pg.content_rect(i, j)).intersect(geo.cells);
if !clip.is_positive() {
continue;
}
let rows = (geo.row_at(sh, clip.top()).max(r0), geo.row_at(sh, clip.bottom() - 0.5).min(r1));
let cols = (geo.col_at(sh, clip.left()).max(c0), geo.col_at(sh, clip.right() - 0.5).min(c1));
if rows.0 > rows.1 || cols.0 > cols.1 {
continue;
}
quads.push((rows, cols, clip));
if painted >= MAX_PAGES_PAINTED {
continue;
}
painted += 1;
let pc = p.with_clip_rect(clip);
grid::paint_quadrant(&pc, geo, wb, si, sh, rows, cols, cf, t);
if !pg.is_data_page(i, j) {
// "Click to add data" pages: faded until something is typed there.
pc.rect_filled(clip, 0.0, t.grid_bg.gamma_multiply(0.75));
let size = (14.0 * geo.z / DISPLAY_SCALE).clamp(8.0, 22.0);
pc.text(clip.center(), Align2::CENTER_CENTER, tr(app, "Click to add data"), theme::ui_font(size), Color32::from_gray(150));
}
// The selection outline may straddle the margin edge (as at A1 in Normal view).
let sel_clip = clip.expand(3.0).intersect(page).intersect(geo.cells);
grid::paint_selection(&p.with_clip_rect(sel_clip), geo, sh, sel, t, app);
}
quads
}
fn tr<'a>(app: &SheetApp, s: &'a str) -> &'a str {
app.ui.language.tr(s)
}
/// Section rectangles (left, centre, right) of a page's header or footer on screen.
fn section_rects(geo: &Geo, pg: &PageGrid, (i, j): (usize, usize), footer: bool) -> [Rect; 3] {
let (px, py, w, h) = pg.page_rect(i, j);
let [l, r, t, b, ..] = pg.margins;
let (y0, y1) = if footer { (py + h - b, py + h) } else { (py, py + t) };
let inner = (w - l - r).max(3.0);
let third = inner / 3.0;
let pad = 3.0 / geo.z as f64;
let mut out = [Rect::NOTHING; 3];
for (k, slot) in out.iter_mut().enumerate() {
let x = px + l + third * k as f64;
*slot = screen_rect(geo, (x, y0 + pad, third, (y1 - y0 - 2.0 * pad).max(1.0)));
}
out
}
fn hf_font(geo: &Geo, size_pt: Option<f32>, bold: bool) -> FontId {
let px = size_pt.unwrap_or(10.0).clamp(1.0, 72.0) * 96.0 / 72.0 * geo.z;
let px = px.clamp(4.0, 120.0);
if bold { theme::ui_bold(px) } else { theme::ui_font(px) }
}
/// Draws headers and footers on the visible printed pages, and the "Add header" hints on hover.
pub(crate) fn paint_headers_footers(app: &SheetApp, ui: &egui::Ui, p: &Painter, geo: &Geo, wb: &Workbook, sh: &Sheet) {
let Some(pg) = geo.paper.clone() else { return };
let Some(d) = app.session.active() else { return };
let p = p.with_clip_rect(geo.cells);
let editing = app.grid.hf_edit.as_ref().map(|e| (e.page, e.footer));
let hover = ui.input(|i| i.pointer.hover_pos()).filter(|q| geo.cells.contains(*q)).and_then(|q| {
let (x, y) =
(geo.scroll.x as f64 + ((q.x - geo.cells.left()) / geo.z) as f64, geo.scroll.y as f64 + ((q.y - geo.cells.top()) / geo.z) as f64);
pg.hf_zone_at(x, y)
});
let needs_text = !sh.print.header.is_empty() || !sh.print.footer.is_empty();
let (date, time) = if needs_text { print::hf_date_time(wb) } else { Default::default() };
for (i, j) in pages_iter(geo, &pg) {
let page = screen_rect(geo, pg.page_rect(i, j));
if !page.intersects(geo.cells) {
continue;
}
for (spec, footer) in [(&sh.print.header, false), (&sh.print.footer, true)] {
if editing == Some(((i, j), footer)) {
continue;
}
let rects = section_rects(geo, &pg, (i, j), footer);
let hovered = hover.is_some_and(|(page, z)| page == (i, j) && z.footer == footer);
if hovered {
for r in &rects {
p.rect_stroke(*r, 0.0, Stroke::new(1.0, Color32::from_gray(185)), StrokeKind::Inside);
}
}
let Some((n, total)) = pg.page_number(i, j).or_else(|| (spec.is_empty()).then_some((1, 1))) else { continue };
if spec.is_empty() {
if hovered {
let label = if footer { "Add footer" } else { "Add header" };
p.text(rects[1].center(), Align2::CENTER_CENTER, tr(app, label), hf_font(geo, Some(10.0), false), Color32::from_gray(120));
}
continue;
}
let cx = HfContext {
page: n,
pages: total,
sheet: sh.name.clone(),
file: d.display_title(),
path: d.path.clone().unwrap_or_default(),
date: date.clone(),
time: time.clone(),
};
let secs = print::expand_header_footer(spec, &cx);
let (_, py, _, h) = pg.page_rect(i, j);
let [_, _, _, _, hm, fm] = pg.margins;
for (k, sec) in secs.iter().enumerate() {
if sec.text.trim().is_empty() {
continue;
}
let font = hf_font(geo, sec.size, sec.bold);
let r = rects[k];
let (anchor, x) = match k {
0 => (Align2::LEFT_TOP, r.left()),
1 => (Align2::CENTER_TOP, r.center().x),
_ => (Align2::RIGHT_TOP, r.right()),
};
let lines: Vec<&str> = sec.text.split('\n').take(20).collect();
let line_h = font.size * 1.2;
let block = line_h * lines.len() as f32;
let top = if footer { screen(geo, 0.0, py + h - fm).y - block } else { screen(geo, 0.0, py + hm).y };
for (li, line) in lines.iter().enumerate() {
p.text(pos2(x, top + li as f32 * line_h), anchor, *line, font.clone(), Color32::BLACK);
}
}
}
}
}
/// Clicks on headers and footers, and the in-place editor. Runs before the grid's own
/// interaction (which stands aside while a header or footer is being edited).
pub(crate) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet, wb: &Workbook) {
// Leaving the sheet or the view commits.
if let Some(e) = &app.grid.hf_edit
&& (e.sheet != wb.active_sheet || sh.view != SheetViewMode::PageLayout || geo.paper.is_none())
{
commit(app);
}
let Some(pg) = geo.paper.clone() else { return };
if resp.clicked_by(egui::PointerButton::Primary)
&& let Some(p) = ui.input(|i| i.pointer.interact_pos())
{
let (x, y) =
(geo.scroll.x as f64 + ((p.x - geo.cells.left()) / geo.z) as f64, geo.scroll.y as f64 + ((p.y - geo.cells.top()) / geo.z) as f64);
match pg.hf_zone_at(x, y) {
Some((page, zone)) if geo.cells.contains(p) => begin(app, wb.active_sheet, sh, page, zone),
_ => {
// A click anywhere else ends the edit; the grid then handles the click.
if app.grid.hf_edit.is_some() {
commit(app);
}
}
}
}
editor(app, ui, geo, &pg);
}
/// Starts (or moves) editing a header or footer section.
pub fn begin(app: &mut SheetApp, sheet: usize, sh: &Sheet, page: (usize, usize), zone: HfZone) {
if let Some(e) = app.grid.hf_edit.as_mut()
&& e.sheet == sheet
&& e.page == page
&& e.footer == zone.footer
{
if e.section != zone.section {
e.section = zone.section;
e.caret = e.texts.get(zone.section).map_or(0, |t| t.chars().count());
e.set_caret = Some(e.caret);
}
e.request_focus = true;
return;
}
if app.grid.hf_edit.is_some() {
commit(app);
}
// A cell being edited is entered first, as when clicking another cell.
if app.editor.is_some() && !app.commit_edit(0, 0, false, false) {
return;
}
let spec = if zone.footer { &sh.print.footer } else { &sh.print.header };
let texts = print::split_header_footer(spec).map(|s| print::codes_to_fields(&s));
let caret = texts.get(zone.section).map_or(0, |t| t.chars().count());
let prev_tab = if app.ui.ribbon_tab == TAB { "Home".to_string() } else { app.ui.ribbon_tab.clone() };
app.grid.hf_edit = Some(HfEdit {
sheet,
page,
footer: zone.footer,
section: zone.section.min(2),
original: texts.clone(),
texts,
request_focus: true,
caret,
set_caret: Some(caret),
prev_tab,
});
app.ui.ribbon_tab = TAB.to_string();
app.ui.ribbon_collapsed = false;
}
/// Ends editing and writes the header or footer when it changed (one undo step).
pub fn commit(app: &mut SheetApp) {
let Some(e) = app.grid.hf_edit.take() else { return };
if app.ui.ribbon_tab == TAB {
app.ui.ribbon_tab = e.prev_tab.clone();
}
if e.texts == e.original {
return;
}
let spec = print::join_header_footer(&e.texts.clone().map(|s| print::fields_to_codes(&s)));
let key = if e.footer { "footer" } else { "header" };
app.run_or_alert("pageLayout.headerFooter", json!({"sheet": e.sheet, key: spec}));
}
/// Ends editing without writing.
pub fn cancel(app: &mut SheetApp) {
if let Some(e) = app.grid.hf_edit.take()
&& app.ui.ribbon_tab == TAB
{
app.ui.ribbon_tab = e.prev_tab;
}
}
/// Inserts a field (`&[Page]`…) at the caret of the section being edited.
pub fn insert_field(app: &mut SheetApp, field: &str) {
let Some(e) = app.grid.hf_edit.as_mut() else { return };
let Some(text) = e.texts.get_mut(e.section) else { return };
let at = text.char_indices().nth(e.caret).map_or(text.len(), |(b, _)| b);
text.insert_str(at, field);
e.caret += field.chars().count();
e.set_caret = Some(e.caret);
e.request_focus = true;
}
/// Moves editing to the header or footer of the same page (Go to Header / Go to Footer).
pub fn go_to(app: &mut SheetApp, footer: bool) {
let Some(e) = app.grid.hf_edit.as_ref() else { return };
if e.footer == footer {
return;
}
let (sheet, page, section) = (e.sheet, e.page, e.section);
commit(app);
let Some(sh) = app.session.active().and_then(|d| d.wb.sheet(sheet)).cloned() else { return };
begin(app, sheet, &sh, page, HfZone { footer, section });
}
fn editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, pg: &PageGrid) {
let Some(mut e) = app.grid.hf_edit.take() else { return };
if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Escape)) {
app.grid.hf_edit = Some(e);
cancel(app);
return;
}
let rects = section_rects(geo, pg, e.page, e.footer);
let p = ui.painter().with_clip_rect(geo.cells);
let t = Tokens::get(ui.ctx());
let font = hf_font(geo, Some(10.0), false);
let mut switch_to = None;
for (k, r) in rects.iter().enumerate() {
if !r.intersects(geo.cells) {
continue;
}
p.rect_filled(*r, 0.0, Color32::WHITE);
let active = k == e.section;
p.rect_stroke(
*r,
0.0,
Stroke::new(if active { 1.5 } else { 1.0 }, if active { t.sel_border } else { Color32::from_gray(160) }),
StrokeKind::Inside,
);
if active {
continue;
}
let Some(text) = e.texts.get(k) else { continue };
let anchor = [Align2::LEFT_TOP, Align2::CENTER_TOP, Align2::RIGHT_TOP][k];
let x = [r.left() + 3.0, r.center().x, r.right() - 3.0][k];
p.text(pos2(x, r.top() + 2.0), anchor, text, font.clone(), Color32::BLACK);
if ui.interact(*r, ui.id().with(("hf_section", k)), egui::Sense::click()).clicked() {
switch_to = Some(k);
}
}
// Scrolled away: keep the edit, show it again when the page comes back.
let Some(r) = rects.get(e.section).copied().filter(|r| r.intersects(geo.cells)) else {
app.grid.hf_edit = Some(e);
return;
};
let id = egui::Id::new("gridcraft.header_footer_editor");
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(3.0, 2.0)).intersect(geo.cells)));
let align = [egui::Align::LEFT, egui::Align::Center, egui::Align::RIGHT][e.section.min(2)];
if let Some(text) = e.texts.get_mut(e.section) {
let mut output = TextEdit::multiline(text)
.id(id)
.frame(egui::Frame::NONE)
.font(font)
.text_color(Color32::BLACK)
.background_color(Color32::WHITE)
.horizontal_align(align)
.desired_width(r.width() - 6.0)
.desired_rows(1)
.lock_focus(true)
.show(&mut child);
if e.request_focus {
output.response.request_focus();
e.request_focus = false;
}
if let Some(c) = e.set_caret.take() {
output.state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(c))));
output.state.store(ui.ctx(), id);
} else if let Some(cr) = output.cursor_range {
e.caret = cr.primary.index.into();
}
}
if let Some(k) = switch_to {
e.section = k;
e.caret = e.texts.get(k).map_or(0, |t| t.chars().count());
e.set_caret = Some(e.caret);
e.request_focus = true;
}
app.grid.hf_edit = Some(e);
}
/// Horizontal and vertical rulers (`outer` is the grid area including the rulers).
pub(crate) fn paint_rulers(app: &SheetApp, p: &Painter, outer: Rect, geo: &Geo, t: &Tokens) {
let Some(pg) = geo.paper.clone() else { return };
if geo.rect.top() - outer.top() < RULER - 0.5 {
return;
}
let metric = metric(app);
// Screen points per unit: an inch is 96 sheet pixels at the print scale.
let inch = 96.0 / pg.scale.clamp(0.1, 4.0) * geo.z;
let unit = if metric { inch / 2.54 } else { inch };
let minor = if metric { 2.0 } else { 4.0 };
let label_every = ((22.0 / unit).ceil() as usize).max(1);
let font = theme::ui_font(9.5);
let hr = Rect::from_min_max(pos2(geo.cells.left(), outer.top()), pos2(geo.cells.right(), outer.top() + RULER));
let vr = Rect::from_min_max(pos2(outer.left(), geo.cells.top()), pos2(outer.left() + RULER, geo.cells.bottom()));
p.rect_filled(Rect::from_min_max(outer.min, pos2(outer.right(), outer.top() + RULER)), 0.0, t.window);
p.rect_filled(Rect::from_min_max(outer.min, pos2(outer.left() + RULER, outer.bottom())), 0.0, t.window);
let ((i0, i1), (j0, j1)) = visible_pages(geo, &pg);
let [ml, mr, mt, mb, ..] = pg.margins;
// One ruler span per page: margins shaded, the printable part light, ticks from the margin.
let span = |p: &Painter, from: f32, to: f32, m0: f32, m1: f32, horizontal: bool, area: Rect| {
let band = |a: f32, b: f32| {
if horizontal {
Rect::from_min_max(pos2(a, area.top() + 2.0), pos2(b, area.bottom() - 2.0))
} else {
Rect::from_min_max(pos2(area.left() + 2.0, a), pos2(area.right() - 2.0, b))
}
};
let pc = p.with_clip_rect(area);
pc.rect_filled(band(from, to), 0.0, t.header_sel_bg);
pc.rect_filled(band(m0, m1), 0.0, t.header_bg);
if unit < 2.0 {
return;
}
let steps = (((m1 - m0) / unit * minor).ceil() as usize).min(4_000);
for s in 0..=steps {
let pos = m0 + s as f32 * unit / minor;
if pos > m1 + 0.5 {
break;
}
let major = s.is_multiple_of(minor as usize);
let len = if major {
6.0
} else if s.is_multiple_of(2) && !metric {
4.0
} else {
2.5
};
if !major && unit / minor < 3.5 {
continue;
}
let n = s / minor as usize;
if major && n > 0 && n.is_multiple_of(label_every) {
let at = if horizontal { pos2(pos, area.center().y) } else { pos2(area.center().x, pos) };
pc.text(at, Align2::CENTER_CENTER, n.to_string(), font.clone(), t.header_text);
continue;
}
let (a, b) = if horizontal {
(pos2(pos, area.bottom() - 2.0 - len), pos2(pos, area.bottom() - 2.0))
} else {
(pos2(area.right() - 2.0 - len, pos), pos2(area.right() - 2.0, pos))
};
pc.line_segment([a, b], Stroke::new(1.0, t.header_text.gamma_multiply(0.6)));
}
};
for j in j0..=j1.min(j0 + 200) {
let (x, _, w, _) = pg.page_rect(0, j);
let a = screen(geo, x, 0.0).x;
let b = screen(geo, x + w, 0.0).x;
span(p, a, b, screen(geo, x + ml, 0.0).x, screen(geo, x + w - mr, 0.0).x, true, hr);
}
for i in i0..=i1.min(i0 + 200) {
let (_, y, _, h) = pg.page_rect(i, 0);
let a = screen(geo, 0.0, y).y;
let b = screen(geo, 0.0, y + h).y;
span(p, a, b, screen(geo, 0.0, y + mt).y, screen(geo, 0.0, y + h - mb).y, false, vr);
}
p.line_segment([pos2(outer.left(), outer.top() + RULER - 0.5), pos2(outer.right(), outer.top() + RULER - 0.5)], Stroke::new(1.0, t.header_line));
p.line_segment(
[pos2(outer.left() + RULER - 0.5, outer.top()), pos2(outer.left() + RULER - 0.5, outer.bottom())],
Stroke::new(1.0, t.header_line),
);
}
/// The Header & Footer contextual ribbon tab.
pub fn ribbon_tab(app: &mut SheetApp, ui: &mut egui::Ui) {
use crate::widgets::big_button;
let Some(e) = app.grid.hf_edit.as_ref() else { return };
let footer = e.footer;
let (sheet_name, file) =
app.session.active().map(|d| (d.wb.sheet(e.sheet).map(|s| s.name.clone()).unwrap_or_default(), d.display_title())).unwrap_or_default();
// Header & Footer presets (shown with sample values, as the result will read).
for (label, part) in [("Header", false), ("Footer", true)] {
let r = big_button(ui, if part { Icon::Footer } else { Icon::Header }, label, label, true);
egui::Popup::menu(&r).show(|ui| {
let presets: [(String, &str); 7] = [
(tr(app, "(none)").to_string(), ""),
("Page 1".into(), "&CPage &P"),
("Page 1 of ?".into(), "&CPage &P of &N"),
(sheet_name.clone(), "&C&A"),
(file.clone(), "&C&F"),
(format!("{sheet_name}, Page 1"), "&C&A, Page &P"),
(format!("{file}, Page 1"), "&C&F, Page &P"),
];
for (text, spec) in presets {
if ui.button(text).clicked() {
let sheet = app.grid.hf_edit.as_ref().map(|e| e.sheet).unwrap_or(0);
cancel(app);
app.run_or_alert("pageLayout.headerFooter", json!({"sheet": sheet, (if part { "footer" } else { "header" }): spec}));
ui.close();
}
}
});
}
sep(ui);
for (icon, label, tip, field) in [
(Icon::PageNumber, "Page\nNumber", "Page Number", "&[Page]"),
(Icon::PageCount, "Number\nof Pages", "Number of Pages", "&[Pages]"),
(Icon::Calendar, "Current\nDate", "Current Date", "&[Date]"),
(Icon::Clock, "Current\nTime", "Current Time", "&[Time]"),
(Icon::Folder, "File\nPath", "File Path", "&[Path]&[File]"),
(Icon::FileName, "File\nName", "File Name", "&[File]"),
(Icon::Sheet, "Sheet\nName", "Sheet Name", "&[Tab]"),
] {
if big_button(ui, icon, label, tip, false).clicked() {
insert_field(app, field);
}
}
sep(ui);
if big_button(ui, Icon::Header, "Go to\nHeader", "Go to Header", false).clicked() && footer {
go_to(app, false);
}
if big_button(ui, Icon::Footer, "Go to\nFooter", "Go to Footer", false).clicked() && !footer {
go_to(app, true);
}
}
fn sep(ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(13.0, 72.0), egui::Sense::hover());
ui.painter().line_segment([pos2(r.center().x, r.top() + 6.0), pos2(r.center().x, r.bottom() - 6.0)], Stroke::new(1.0, t.separator));
}
+11
View File
@@ -199,6 +199,7 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
"Automate" => automate(app, ui),
"Table Design" => table_design(app, ui),
"Chart Design" => chart_design(app, ui),
crate::paper_view::TAB => crate::paper_view::ribbon_tab(app, ui),
_ => home(app, ui),
}
});
@@ -244,6 +245,9 @@ fn contextual_tabs(app: &SheetApp) -> Vec<&'static str> {
if app.selected_chart.is_some_and(|id| sh.charts.iter().any(|c| c.id == id)) {
v.push("Chart Design");
}
if app.grid.hf_edit.is_some() {
v.push(crate::paper_view::TAB);
}
}
v
}
@@ -1635,7 +1639,14 @@ fn view(app: &mut SheetApp, ui: &mut Ui) {
sep(ui);
let s = app.session.active().and_then(|d| d.wb.active().map(|s| (s.show_gridlines, s.show_headings))).unwrap_or((true, true));
let lang = app.ui.language;
let page_layout =
app.session.active().and_then(|d| d.wb.active().map(|s| s.view == gridcraft_engine::model::SheetViewMode::PageLayout)).unwrap_or(false);
ui.vertical(|ui| {
// Rulers exist only in Page Layout view.
let mut ruler = !app.session.hide_ruler;
if ui.add_enabled(page_layout, egui::Checkbox::new(&mut ruler, lang.tr("Ruler"))).changed() {
act(app, "view.ruler", json!({"on": ruler}));
}
let mut fb = app.ui.formula_bar;
if ui.checkbox(&mut fb, lang.tr("Formula Bar")).changed() {
app.ui.formula_bar = fb;
+1 -1
View File
@@ -249,7 +249,7 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) {
(Icon::Normal, "Normal", "normal", "view.normal"),
] {
let (r, resp) = ui.allocate_exact_size(vec2(26.0, 20.0), Sense::click());
let cur = if app.session.view_mode.is_empty() { "normal" } else { app.session.view_mode.as_str() };
let cur = app.session.active().and_then(|d| d.wb.active().map(|s| s.view.as_str())).unwrap_or("normal");
if cur == mode {
ui.painter().rect_filled(r, 3.0, t.pressed);
} else if resp.hovered() {
+4
View File
@@ -25,6 +25,8 @@ pub struct Tokens {
pub pressed: Color32,
pub separator: Color32,
pub grid_bg: Color32,
/// Behind the pages in Page Layout view.
pub desk: Color32,
pub gridline: Color32,
pub header_bg: Color32,
pub header_text: Color32,
@@ -62,6 +64,7 @@ impl Tokens {
pressed: Color32::from_rgb(0xDD, 0xDD, 0xDD),
separator: Color32::from_rgb(0xE0, 0xE0, 0xE0),
grid_bg: Color32::WHITE,
desk: Color32::from_rgb(0xDE, 0xDE, 0xDE),
gridline: Color32::from_rgb(0xE1, 0xE1, 0xE1),
header_bg: Color32::from_rgb(0xF8, 0xF8, 0xF8),
header_text: Color32::from_rgb(0x42, 0x42, 0x42),
@@ -99,6 +102,7 @@ impl Tokens {
pressed: Color32::from_rgb(0x4D, 0x4D, 0x4D),
separator: Color32::from_rgb(0x48, 0x48, 0x48),
grid_bg: Color32::WHITE,
desk: Color32::from_rgb(0x3A, 0x3A, 0x3A),
gridline: Color32::from_rgb(0xE1, 0xE1, 0xE1),
header_bg: Color32::from_rgb(0x30, 0x30, 0x30),
header_text: Color32::from_rgb(0xCC, 0xCC, 0xCC),
+126
View File
@@ -0,0 +1,126 @@
//! Page Layout view through real pointer and keyboard input: cells are hit through the page
//! transform, and headers are edited in place, committed on a click elsewhere, and undoable.
use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2, vec2};
use gridcraft_engine::Session;
use gridcraft_engine::core::CellRef;
use gridcraft_engine::model::SheetViewMode;
use gridcraft_ui_egui::SheetApp;
use gridcraft_ui_egui::grid::Geo;
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn fixture() -> Harness {
let mut session = Session::new();
session.new_workbook();
for r in 1..=60 {
session.execute("cell.set", json!({"cell": format!("A{r}"), "input": r.to_string()})).unwrap();
}
session.execute("pageLayout.headerFooter", json!({"header": "&LLeft"})).unwrap();
session.execute("view.pageLayout", json!({})).unwrap();
let mut h = egui_kittest::Harness::builder()
.with_size(vec2(1200.0, 900.0))
.with_step_dt(1.0 / 60.0)
.with_os(egui::os::OperatingSystem::Mac)
.build_ui_state(
|ui, app: &mut SheetApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
SheetApp::new(session, Default::default()),
);
h.run_steps(4);
h
}
fn geo(h: &Harness) -> Geo {
h.state().grid.geo.clone().unwrap()
}
fn click(h: &mut Harness, p: Pos2) {
// Keep clicks apart from egui's double-click window.
h.run_steps(40);
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
h.step();
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
fn header(h: &Harness) -> String {
h.state().session.doc().unwrap().wb.active().unwrap().print.header.clone()
}
/// Screen point of a layout point.
fn screen(g: &Geo, x: f64, y: f64) -> Pos2 {
pos2(g.cells.left() + (x - g.scroll.x as f64) as f32 * g.z, g.cells.top() + (y - g.scroll.y as f64) as f32 * g.z)
}
#[test]
fn cells_are_hit_through_the_page_transform() {
let mut h = fixture();
let g = geo(&h);
let pg = g.paper.clone().expect("Page Layout view lays out pages");
let app = h.state();
let sh = app.session.doc().unwrap().wb.active().unwrap();
assert_eq!(sh.view, SheetViewMode::PageLayout);
// Cells start inside the first page's margins, not at the grid's corner.
let a1 = g.cell_rect(sh, CellRef::new(0, 0));
let content = screen(&g, pg.page_x(0) + pg.margins[0], pg.page_y(0) + pg.margins[2]);
assert!((a1.left() - content.x).abs() < 0.5 && (a1.top() - content.y).abs() < 0.5, "{a1:?} vs {content:?}");
let c5 = g.cell_rect(sh, CellRef::parse("C5").unwrap()).center();
assert_eq!(g.cell_at(sh, c5), CellRef::parse("C5").unwrap());
click(&mut h, c5);
assert_eq!(h.state().session.doc().unwrap().selection.active, CellRef::parse("C5").unwrap());
// A click in the gap left of the page selects nothing new.
let gap = screen(&g, pg.gap / 2.0, pg.page_y(0) + pg.margins[2] + 30.0);
click(&mut h, gap);
assert_eq!(h.state().session.doc().unwrap().selection.active, CellRef::parse("C5").unwrap());
}
#[test]
fn header_is_edited_in_place_committed_by_a_click_elsewhere_and_undoable() {
let mut h = fixture();
let g = geo(&h);
let pg = g.paper.clone().unwrap();
// The centre third of the first page's top margin.
let (px, py, w, _) = pg.page_rect(0, 0);
let centre = screen(&g, px + w / 2.0, py + pg.margins[2] / 2.0);
click(&mut h, centre);
{
let app = h.state();
let e = app.grid.hf_edit.as_ref().expect("clicking the top margin edits the header");
assert_eq!((e.footer, e.section), (false, 1));
assert_eq!(app.ui.ribbon_tab, "Header & Footer");
}
h.input_mut().events.push(Event::Text("Report ".into()));
h.run_steps(2);
gridcraft_ui_egui::paper_view::insert_field(h.state_mut(), "&[Page]");
h.run_steps(2);
assert_eq!(h.state().grid.hf_edit.as_ref().unwrap().texts[1], "Report &[Page]");
assert_eq!(header(&h), "&LLeft", "nothing is written while editing");
// Clicking a cell commits (one undo step) and selects the cell.
let sh_geo = geo(&h);
let b3 = {
let app = h.state();
let sh = app.session.doc().unwrap().wb.active().unwrap();
sh_geo.cell_rect(sh, CellRef::parse("B3").unwrap()).center()
};
click(&mut h, b3);
assert!(h.state().grid.hf_edit.is_none());
assert_eq!(header(&h), "&LLeft&CReport &P");
assert_eq!(h.state().session.doc().unwrap().selection.active, CellRef::parse("B3").unwrap());
assert_ne!(h.state().ui.ribbon_tab, "Header & Footer");
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(header(&h), "&LLeft");
// Escape cancels an edit without writing.
click(&mut h, centre);
h.input_mut().events.push(Event::Text("x".into()));
h.run_steps(2);
h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert!(h.state().grid.hf_edit.is_none());
assert_eq!(header(&h), "&LLeft");
}
+1
View File
@@ -208,6 +208,7 @@ fn read_views(e: &El, sheet: &mut Sheet) {
sheet.show_formulas = v.flag("showFormulas", false);
sheet.show_zeros = v.flag("showZeros", true);
sheet.right_to_left = v.flag("rightToLeft", false);
sheet.view = v.attr("view").and_then(gridcraft_model::SheetViewMode::parse).unwrap_or_default();
if let Some(z) = v.attr_u32("zoomScale") {
sheet.zoom = z.clamp(10, 400) as u16;
}
+3
View File
@@ -152,6 +152,9 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> (
if sheet.right_to_left {
s.push_str(" rightToLeft=\"1\"");
}
if sheet.view != gridcraft_model::SheetViewMode::Normal {
let _ = write!(s, " view=\"{}\"", sheet.view.as_str());
}
if sheet.zoom != 100 && (10..=400).contains(&sheet.zoom) {
let _ = write!(s, " zoomScale=\"{0}\" zoomScaleNormal=\"{0}\"", sheet.zoom);
}
+2
View File
@@ -118,6 +118,7 @@ fn sample() -> Workbook {
s.show_zeros = false;
s.right_to_left = true;
s.zoom = 125;
s.view = gridcraft_model::SheetViewMode::PageLayout;
s.view_active = at("C7");
let red_b = Box::new(red.clone());
@@ -398,6 +399,7 @@ fn full_roundtrip() {
assert_eq!(b.tab_color, a.tab_color);
assert_eq!((b.show_gridlines, b.show_headings, b.show_zeros, b.right_to_left, b.zoom), (false, false, false, true, 125));
assert_eq!(b.view_active, a.view_active);
assert_eq!(b.view, gridcraft_model::SheetViewMode::PageLayout, "sheetView view=\"pageLayout\" round-trips");
assert_eq!(b.cond_formats, a.cond_formats);
assert_eq!(b.validations, a.validations);
assert_eq!(b.hyperlinks, a.hyperlinks);
+4 -3
View File
@@ -1,6 +1,6 @@
# Gaps
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the full re-measure against Excel for Mac 16.113.4 and the issue tracker) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (§14 Page Layout view, #329) · **Target:** Microsoft Excel (Microsoft 365)
Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone
using GridCraft for real work first). This is the work list: pick from the top, check the open
@@ -102,8 +102,9 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
**Estimate:** 22–35 h.
### 14. Page Layout view and print polish (U, F)
- No paper view with rulers and in-place header/footer editing; web print exports only (#141, PR
#143). **Estimate:** 12–20 h.
- Page Layout view landed (#329): paper view with rulers and in-place header/footer editing.
Remaining: pictures in headers/footers, Different First Page / Odd & Even pages, dragging
margins on the ruler; web print exports only (#141, PR #143). **Estimate:** 5–10 h.
### 15. Remaining functions and an oracle suite (F)
- 22 missing, 9 stubs; no Excel-computed oracle corpus; #137, #138. **Estimate:** 18–30 h.
+4 -3
View File
@@ -1,6 +1,6 @@
# Target-app parity: GridCraft vs Microsoft Excel
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first full assessment; re-measure against Excel for Mac 16.113.4, replacing the estimates that lived in ROADMAP.md) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Page Layout view, #329) · **Target:** Microsoft Excel (Microsoft 365)
This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it,
[`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail:
@@ -86,7 +86,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2
| Sort, filter, tables, validation, outline | 8 | ~82% | ~70% | 10–16 | Multi-level sort, AutoFilter, Advanced Filter, tables with totals, validation, outline, subtotals, consolidate. Bugs: subtotal header (#131), validation edits (#129), table style panic caught (#130); no slicers on tables |
| PivotTables | 7 | ~65% | ~45% | 25–40 | Insert/Recommended, field list, three layouts, date grouping, show-values-as, refresh, XLSX round-trip. Missing: slicers, timelines, calculated fields/items, PivotCharts, value/label filters, number grouping, GETPIVOTDATA, data-model pivots. Round-trip never checked in Excel |
| Charts and sparklines | 8 | ~50% | ~30% | 40–60 | 25 chart kinds, all 2-D. The chart model holds title, legend position, a data-labels flag, gridlines flag and axis titles; no axis scaling, trendlines, error bars, per-point formatting, chart sheets, 3-D, surface, map, Pareto. Several kinds don't survive XLSX round trip (open PRs #158, #161). [chart-parity.md](chart-parity.md) |
| Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, Page Break Preview, headers/footers, PDF export and printing. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) |
| Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, Page Break Preview, Page Layout view with in-place headers/footers (#329), PDF export and printing. The PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) |
| What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables. Missing: Solver, Analysis ToolPak, Forecast Sheet, Analyze Data |
| Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) |
| Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes. Missing: SmartArt, WordArt, equations, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) |
@@ -133,7 +133,7 @@ functions, co-authoring and admin, specialist hardware, and languages beyond the
| Sort, filter, tables, validation | 10 | 70 | Feature-area table |
| PivotTables (core: fields, layouts, grouping, refresh) | 8 | 60 | Slicers and calculated fields excluded as less frequent |
| Charts (column, bar, line, pie, scatter, combo) | 9 | 45 | Common kinds work and survive save; no axis options or trendlines; resize broken (#190) |
| Page layout, print, PDF | 5 | 60 | Latin text exports; no Page Layout view |
| Page layout, print, PDF | 5 | 60 | Latin text exports |
| Comments, notes, protection | 3 | 55 | Feature-area table |
| Pictures and shapes | 2 | 45 | Feature-area table |
| **Average depth** | 100 | **69.5** | |
@@ -332,6 +332,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Page Layout view landed (#329); page layout notes updated, no score change |
| 2026-10-10 | minor | Readiness-by-audience table: hours to ~95% for each of the three numbers (full 650–1,050, mainstream 200–310, essentials 60–100). Full number confirmed as the additive weighted sum over the written dimension weights (no change) |
| 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%) with written weights and discounts, and user-evidence counts; re-checked the full number after the 10-10 merges (charts 38→30, localization 5→10; still 51.6, ~50%) |
| 2026-10-10 | minor | Stage checked against the new alpha gate (six core workflows, all pass): stays alpha |
+5 -4
View File
@@ -1,6 +1,6 @@
# 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 (Page Layout view, #329) · **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
@@ -15,7 +15,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes
| Element | Excel | GridCraft | Status |
|---|---|---|---|
| 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: tabs, groups, split buttons, galleries, contextual tabs | yes | yes (Table Design, Chart Design, Format, PivotTable, Header & Footer tabs) | done; missing contextual tabs for pictures, shapes, sparklines, slicers |
| Ribbon collapse, simplified ribbon, customize ribbon/QAT | yes | partial | customization missing (File ▸ Options is the one missing File command) |
| 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 |
@@ -24,7 +24,7 @@ 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) |
| Page Layout view (paper view with rulers and header editing) | yes | **no** (Page Break Preview only) | 8–12 h |
| Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab | header/footer pictures, Different First Page / Odd & Even, ruler margin dragging missing (1–3 h) |
| Multiple windows per workbook | yes | no (#162) | see [hardware-parity.md](hardware-parity.md) |
## Grid interaction
@@ -84,12 +84,13 @@ by rule and will never match Office's, by design.
## Work (50–80 h)
Key tips 3–5 · shortcut audit 6–10 · context menus and Quick Analysis 5–8 · Page Layout view
8–12 · Options dialog and ribbon/QAT customization 6–10 · accessibility 12–18 · selection and
polish 1–3 · Options dialog and ribbon/QAT customization 6–10 · accessibility 12–18 · selection and
scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Page Layout view with rulers and in-place headers and footers (#329) |
| 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 |