Files
gridcraft/crates/engine/src/cmd/view.rs
T
midasmatterhorn 18fecf8e8c engine: pageLayout.printTitles only touches the axes it is given
print_titles read both rows and cols as Options and assigned both
unconditionally, so {"rows":"2:2"} set title_cols = None and
{"cols":"A:A"} set title_rows = None: the repeated rows or columns
disappeared from every printed page with no message, and there was no
way to set or clear just one axis. The sibling pageLayout.pageSetup
already distinguishes "key absent" from "key present but empty"; read
both axes the same way — absent leaves the axis alone, empty/null clears
it, anything else that is not a range is an error rather than a silent
wipe.

Regression test: print_titles_keeps_the_axis_it_is_not_given — on main
the rows-only call clears the columns; with the fix each axis is touched
only when named, an explicit empty value still clears, and a bad range
errors.
2026-10-15 17:18:19 +03:00

447 lines
18 KiB
Rust

//! View tab (sheet view state that's saved with the file) and Page Layout settings.
use gridcraft_core::{CellRef, RangeRef};
use gridcraft_model::Orientation;
use serde_json::{Value as Json, json};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"view.freezePanes",
"Freeze Panes",
["View", "Window", "Freeze Panes"],
None,
"{cell?: \"B2\" (rows above and columns left of it freeze)}",
has_doc,
freeze_panes
),
cmd!("view.freezeTopRow", "Freeze Top Row", ["View", "Window", "Freeze Panes"], None, "{}", has_doc, |s, _| set_freeze(s, Some((1, 0)))),
cmd!("view.freezeFirstColumn", "Freeze First Column", ["View", "Window", "Freeze Panes"], None, "{}", has_doc, |s, _| set_freeze(
s,
Some((0, 1))
)),
cmd!("view.unfreezePanes", "Unfreeze Panes", ["View", "Window", "Freeze Panes"], None, "{}", has_doc, |s, _| set_freeze(s, None)),
cmd!("view.gridlines", "Gridlines", ["View", "Show"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "gridlines")),
cmd!("view.headings", "Headings", ["View", "Show"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "headings")),
cmd!("view.showZeros", "Show Zeros", [], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "zeros")),
cmd!("view.rightToLeft", "Sheet Right-to-Left", ["Page Layout", "Sheet Options"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "rtl")),
cmd!("view.zoom", "Zoom", ["View", "Zoom"], None, "{percent: 10..400}", has_doc, zoom),
cmd!("view.zoom100", "100%", ["View", "Zoom"], None, "{}", has_doc, |s, _| zoom(s, &json!({"percent": 100}))),
cmd!(query "view.formulaBar", "Formula Bar", ["View", "Show"], None, "{on?: bool}", always, |s, p| {
s.prefs.formula_bar = bool_param(p, "on").unwrap_or(!s.prefs.formula_bar);
Ok(json!({"on": s.prefs.formula_bar}))
}),
cmd!("view.zoomToSelection", "Zoom to Selection", ["View", "Zoom"], None, "{viewWidth?, viewHeight?}", has_doc, zoom_selection),
cmd!("pageLayout.orientation", "Orientation", ["Page Layout", "Page Setup"], None, "{orientation: portrait|landscape}", has_doc, orientation),
cmd!("pageLayout.size", "Size", ["Page Layout", "Page Setup"], None, "{paper: Letter|Legal|A4|A3|A5|Tabloid|Executive}", has_doc, paper),
cmd!(
"pageLayout.margins",
"Margins",
["Page Layout", "Page Setup"],
None,
"{preset?: normal|wide|narrow, left?, right?, top?, bottom?, header?, footer? (inches)}",
has_doc,
margins
),
cmd!(
"pageLayout.printArea",
"Set Print Area",
["Page Layout", "Page Setup", "Print Area"],
None,
"{range?, clear?: bool}",
has_doc,
print_area
),
cmd!(
"pageLayout.printTitles",
"Print Titles",
["Page Layout", "Page Setup"],
None,
"{rows?: \"1:1\", cols?: \"A:A\"}",
has_doc,
print_titles
),
cmd!(
"pageLayout.scaleToFit",
"Scale to Fit",
["Page Layout", "Scale to Fit"],
None,
"{scale?: 100, width?: pages, height?: pages}",
has_doc,
scale_fit
),
cmd!("pageLayout.printGridlines", "Print Gridlines", ["Page Layout", "Sheet Options"], None, "{on?}", has_doc, |s, p| sheet_flag(
s,
p,
"printGridlines"
)),
cmd!("pageLayout.printHeadings", "Print Headings", ["Page Layout", "Sheet Options"], None, "{on?}", has_doc, |s, p| sheet_flag(
s,
p,
"printHeadings"
)),
cmd!(
"pageLayout.breaks",
"Breaks",
["Page Layout", "Page Setup"],
None,
"{insert?: bool, remove?: bool, reset?: bool, cell?}",
has_doc,
breaks
),
cmd!(
"pageLayout.headerFooter",
"Header & Footer",
["Insert", "Text"],
None,
"{sheet?, header?: \"&C&P\", footer?: \"&CPage &P of &N\", differentFirst?: bool, differentOddEven?: bool, firstHeader?, firstFooter?, evenHeader?, evenFooter?} or one section: {sheet?, page?: odd|even|first, part: header|footer, section: left|center|right, text: \"Page &[Page] of &[Pages]\"} (field names or codes) → {header, footer, differentFirst, differentOddEven, firstHeader, firstFooter, evenHeader, evenFooter}",
has_doc,
header_footer
),
cmd!(
"pageLayout.theme",
"Themes",
["Page Layout", "Themes"],
None,
"{name: Craft|Slate|Meadow|Ember|Ocean|Orchid|Graphite, colors?: [12 hex], majorFont?, minorFont?}",
has_doc,
theme
),
]
}
fn freeze_panes(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
if sh.freeze.is_some() && p.get("cell").is_none() {
return set_freeze(s, None);
}
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
let top = sh.view_top_left;
let rows = at.row.saturating_sub(top.row);
let cols = at.col.saturating_sub(top.col);
let _ = (rows, cols);
let (rows, cols) = (at.row, at.col);
set_freeze(s, if rows == 0 && cols == 0 { None } else { Some((rows, cols)) })
}
fn set_freeze(s: &mut Session, f: Option<(u32, u32)>) -> Result<Json> {
edit(s, |cx| {
let si = cx.sheet_index();
cx.sheet_mut(si)?.freeze = f;
Ok(json!({"freeze": f}))
})
}
fn sheet_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
edit(s, |cx| {
let si = cx.sheet_index();
let sh = cx.sheet_mut(si)?;
let slot = match which {
"gridlines" => &mut sh.show_gridlines,
"headings" => &mut sh.show_headings,
"zeros" => &mut sh.show_zeros,
"rtl" => &mut sh.right_to_left,
"printGridlines" => &mut sh.print.gridlines,
_ => &mut sh.print.headings,
};
*slot = bool_param(p, "on").unwrap_or(!*slot);
Ok(json!({"on": *slot}))
})
}
fn zoom(s: &mut Session, p: &Json) -> Result<Json> {
let z = f64_param(p, "percent").ok_or_else(|| bad("view.zoom", "missing `percent`"))?.clamp(10.0, 400.0) as u16;
let d = s.doc_mut()?;
let dirty = d.is_dirty();
let wb = std::sync::Arc::make_mut(&mut d.wb);
let si = wb.active_sheet;
if let Some(sh) = wb.sheet_mut(si) {
sh.zoom = z;
}
if !dirty {
d.saved = d.wb.clone();
}
Ok(json!({"zoom": z}))
}
fn zoom_selection(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc()?;
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let r = d.selection.current();
let w = (sh.col_left(r.end.col + 1) - sh.col_left(r.start.col)).max(1.0);
let h = (sh.row_top(r.end.row + 1) - sh.row_top(r.start.row)).max(1.0);
let vw = f64_param(p, "viewWidth").unwrap_or(1200.0);
let vh = f64_param(p, "viewHeight").unwrap_or(700.0);
let z = ((vw / w).min(vh / h) * 100.0).clamp(10.0, 400.0).floor();
zoom(s, &json!({"percent": z}))
}
fn orientation(s: &mut Session, p: &Json) -> Result<Json> {
let o = if str_param(p, "orientation") == Some("landscape") { Orientation::Landscape } else { Orientation::Portrait };
edit(s, |cx| {
let si = cx.sheet_index();
cx.sheet_mut(si)?.print.orientation = o;
Ok(Json::Null)
})
}
fn paper(s: &mut Session, p: &Json) -> Result<Json> {
let paper = str_param(p, "paper").unwrap_or("Letter").to_string();
edit(s, |cx| {
let si = cx.sheet_index();
cx.sheet_mut(si)?.print.paper = paper.clone();
Ok(Json::Null)
})
}
fn margins(s: &mut Session, p: &Json) -> Result<Json> {
let preset = match str_param(p, "preset") {
Some("wide") => Some([1.0, 1.0, 1.0, 1.0, 0.5, 0.5]),
Some("narrow") => Some([0.25, 0.25, 0.75, 0.75, 0.3, 0.3]),
Some("normal") => Some([0.7, 0.7, 0.75, 0.75, 0.3, 0.3]),
_ => None,
};
edit(s, |cx| {
let si = cx.sheet_index();
let m = &mut cx.sheet_mut(si)?.print.margins;
if let Some(pr) = preset {
*m = pr;
}
for (i, k) in ["left", "right", "top", "bottom", "header", "footer"].iter().enumerate() {
if let Some(v) = f64_param(p, k)
&& let Some(slot) = m.get_mut(i)
{
*slot = v.clamp(0.0, 20.0) as f32;
}
}
Ok(Json::Null)
})
}
fn print_area(s: &mut Session, p: &Json) -> Result<Json> {
let clear = bool_param(p, "clear").unwrap_or(false);
let r = target_range(s, p)?;
edit(s, |cx| {
let si = cx.sheet_index();
cx.sheet_mut(si)?.print.print_area = if clear { None } else { Some(r) };
Ok(Json::Null)
})
}
fn print_titles(s: &mut Session, p: &Json) -> Result<Json> {
// An absent key leaves that axis alone, and an empty or null value clears it — the same
// reading `pageLayout.pageSetup` gives `titleRows`/`titleCols`. Assigning `None` for a
// key that was never sent used to wipe the other axis.
let axis = |key: &str, is_cols: bool| -> Result<Option<Option<(u32, u32)>>> {
match p.get(key) {
None => Ok(None),
Some(Json::Null) => Ok(Some(None)),
Some(Json::String(t)) if t.trim().is_empty() => Ok(Some(None)),
Some(Json::String(t)) => match RangeRef::parse(t) {
Some(r) => Ok(Some(Some(if is_cols { (r.start.col, r.end.col) } else { (r.start.row, r.end.row) }))),
None => Err(bad("pageLayout.printTitles", format!("`{key}` is not a range: `{t}`"))),
},
Some(_) => Err(bad("pageLayout.printTitles", format!("`{key}` must be text like \"1:2\" or \"A:A\""))),
}
};
let (rows, cols) = (axis("rows", false)?, axis("cols", true)?);
edit(s, |cx| {
let si = cx.sheet_index();
let pr = &mut cx.sheet_mut(si)?.print;
if let Some(t) = rows {
pr.title_rows = t;
}
if let Some(t) = cols {
pr.title_cols = t;
}
Ok(Json::Null)
})
}
fn scale_fit(s: &mut Session, p: &Json) -> Result<Json> {
edit(s, |cx| {
let si = cx.sheet_index();
let pr = &mut cx.sheet_mut(si)?.print;
if let Some(sc) = f64_param(p, "scale") {
pr.scale = sc.clamp(10.0, 400.0) as u16;
pr.fit_to = None;
}
if p.get("width").is_some() || p.get("height").is_some() {
pr.fit_to = Some((u32_param(p, "width").unwrap_or(0).min(32767) as u16, u32_param(p, "height").unwrap_or(0).min(32767) as u16));
}
Ok(Json::Null)
})
}
fn breaks(s: &mut Session, p: &Json) -> Result<Json> {
let at = cell_param(s, p, "cell")?.unwrap_or(s.doc()?.selection.active);
let insert = bool_param(p, "insert").unwrap_or(false);
let remove = bool_param(p, "remove").unwrap_or(false);
let reset = bool_param(p, "reset").unwrap_or(false);
edit(s, |cx| {
let si = cx.sheet_index();
let pr = &mut cx.sheet_mut(si)?.print;
if reset {
pr.row_breaks.clear();
pr.col_breaks.clear();
} else if insert || !remove {
if at.row > 0 && !pr.row_breaks.contains(&at.row) {
pr.row_breaks.push(at.row);
pr.row_breaks.sort_unstable();
}
if at.col > 0 && !pr.col_breaks.contains(&at.col) {
pr.col_breaks.push(at.col);
pr.col_breaks.sort_unstable();
}
} else {
pr.row_breaks.retain(|r| *r != at.row);
pr.col_breaks.retain(|c| *c != at.col);
}
Ok(Json::Null)
})
}
/// 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}`"))),
};
let page = match str_param(p, "page") {
None => gridcraft_model::HfPage::Odd,
Some(id) => gridcraft_model::HfPage::parse(id).ok_or_else(|| bad(CMD, format!("`page` must be odd, even or first, not `{id}`")))?,
};
Some((page, footer, idx, super::print::fields_to_codes(str_param(p, "text").unwrap_or(""))))
}
};
edit(s, |cx| {
let pr = &mut cx.sheet_mut(si)?.print;
if let Some(h) = str_param(p, "header") {
pr.header = cap(h);
}
if let Some(f) = str_param(p, "footer") {
pr.footer = cap(f);
}
for (key, slot) in [
("firstHeader", &mut pr.first_header),
("firstFooter", &mut pr.first_footer),
("evenHeader", &mut pr.even_header),
("evenFooter", &mut pr.even_footer),
] {
if let Some(t) = str_param(p, key) {
*slot = cap(t);
}
}
if let Some(on) = bool_param(p, "differentFirst") {
pr.different_first = on;
}
if let Some(on) = bool_param(p, "differentOddEven") {
pr.different_odd_even = on;
}
if let Some((page, footer, idx, text)) = &section {
let spec = pr.hf_mut(*page, *footer);
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,
"differentFirst": pr.different_first,
"differentOddEven": pr.different_odd_even,
"firstHeader": pr.first_header,
"firstFooter": pr.first_footer,
"evenHeader": pr.even_header,
"evenFooter": pr.even_footer,
}))
})
}
/// GridCraft's own theme set (original palettes).
pub fn themes() -> Vec<(&'static str, [u32; 12])> {
vec![
("Craft", [0xFFFFFF, 0x000000, 0xE8E8E8, 0x0E2841, 0x156082, 0xE97132, 0x196B24, 0x0F9ED5, 0xA02B93, 0x4EA72E, 0x467886, 0x96607D]),
("Slate", [0xFFFFFF, 0x1F2328, 0xEEF1F4, 0x3B4652, 0x4F6D8A, 0x8AA1B8, 0xC27C4C, 0x6F9A72, 0x9D6C9B, 0xD3B25A, 0x2F6FB0, 0x7A5C8F]),
("Meadow", [0xFFFFFF, 0x16241A, 0xEEF4EA, 0x2E4A33, 0x4D8B3F, 0x9BC53D, 0xE4B33A, 0x2E8C83, 0xC4643C, 0x7B6A9E, 0x2B7A57, 0x6E7F3A]),
("Ember", [0xFFFFFF, 0x2A1A14, 0xF6EEE8, 0x5A2E1E, 0xD9532B, 0xF2994A, 0xF2C94C, 0x8C3B2E, 0x4A6FA5, 0x6F8F4E, 0xB5472A, 0x8C6A5A]),
("Ocean", [0xFFFFFF, 0x0B1E2D, 0xE8F1F7, 0x123A5A, 0x1F78B4, 0x33A6CC, 0x52C7B8, 0x3B5BA5, 0xF2A65A, 0x8E6CB8, 0x1A6FA8, 0x6A7FB0]),
("Orchid", [0xFFFFFF, 0x24152B, 0xF3EDF6, 0x4B2C5E, 0x8E44AD, 0xC56CF0, 0xF78FB3, 0x3DC1D3, 0xF5CD79, 0x63CDDA, 0x7E3FA0, 0xB066B0]),
("Graphite", [0xFFFFFF, 0x111111, 0xEDEDED, 0x333333, 0x595959, 0x7F7F7F, 0xA5A5A5, 0x3C78D8, 0xCC4125, 0x6AA84F, 0x3C78D8, 0x8E7CC3]),
]
}
fn theme(s: &mut Session, p: &Json) -> Result<Json> {
let name = str_param(p, "name").unwrap_or("Craft").to_string();
let colors = p
.get("colors")
.and_then(Json::as_array)
.and_then(|a| {
let v: Vec<u32> = a.iter().filter_map(|c| c.as_str().and_then(|h| u32::from_str_radix(h.trim_start_matches('#'), 16).ok())).collect();
<[u32; 12]>::try_from(v).ok()
})
.or_else(|| themes().into_iter().find(|(n, _)| n.eq_ignore_ascii_case(&name)).map(|(_, c)| c))
.ok_or_else(|| bad("pageLayout.theme", format!("unknown theme `{name}`")))?;
let major = str_param(p, "majorFont").map(str::to_string);
let minor = str_param(p, "minorFont").map(str::to_string);
edit(s, |cx| {
cx.wb.theme.name = name.clone();
cx.wb.theme.colors = colors;
if let Some(m) = &major {
cx.wb.theme.major_font = m.clone();
}
if let Some(m) = &minor {
cx.wb.theme.minor_font = m.clone();
}
Ok(Json::Null)
})
}
#[allow(dead_code)]
fn unused(_: CellRef) {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zoom_100_resets_the_active_sheet_zoom() {
let mut s = Session::new();
s.new_workbook();
s.execute("view.zoom", json!({"percent": 150})).unwrap();
assert_eq!(s.execute("view.zoom100", json!({})).unwrap()["zoom"], 100);
assert_eq!(s.doc().unwrap().wb.active().unwrap().zoom, 100);
}
#[test]
fn formula_bar_toggles_without_a_workbook_and_defaults_to_shown() {
let mut s = Session::new();
assert!(s.prefs.formula_bar);
assert_eq!(s.execute("view.formulaBar", json!({})).unwrap()["on"], false);
assert_eq!(s.execute("view.formulaBar", json!({"on": true})).unwrap()["on"], true);
// A prefs.json saved before the option existed keeps the formula bar shown.
let old: crate::Prefs = serde_json::from_str(r#"{"userName": "Ann"}"#).unwrap();
assert!(old.formula_bar);
}
}