Merge PR #1626: Keyboard: Ctrl+6 hides and shows objects, saved as workbookPr showObjects

Conflicts:
- docs/file-format-parity.md: header and the three .xlsx rows keep main's text plus the show-objects entry; both log rows kept
- docs/ui-parity.md: header keeps main's text plus Ctrl+6; both log rows kept
This commit is contained in:
aiongg
2026-10-22 02:36:15 +00:00
13 changed files with 235 additions and 17 deletions
+11
View File
@@ -35,6 +35,7 @@ pub fn specs() -> Vec<CommandSpec> {
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.showObjects", "Show Objects", [], Some("Ctrl+6"), "{on?}", has_doc, show_objects),
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}))),
@@ -166,6 +167,16 @@ fn sheet_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
})
}
/// Ctrl+6: hides every object, or shows them again. Placeholders (from a file) count as shown.
fn show_objects(s: &mut Session, p: &Json) -> Result<Json> {
use gridcraft_model::ShowObjects;
edit(s, |cx| {
let on = bool_param(p, "on").unwrap_or(cx.wb.show_objects == ShowObjects::None);
cx.wb.show_objects = if on { ShowObjects::All } else { ShowObjects::None };
Ok(json!({"on": on}))
})
}
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()?;
+1 -1
View File
@@ -25,7 +25,7 @@ pub use style::{
Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Theme, Underline,
VAlign, VertAlign,
};
pub use workbook::{CalcMode, CalcSettings, DefinedName, DocProps, Workbook};
pub use workbook::{CalcMode, CalcSettings, DefinedName, DocProps, ShowObjects, Workbook};
/// Font used for new workbooks. Only a name: renderers substitute an available open font
/// (e.g. the metric-compatible Carlito) when it isn't installed.
+18
View File
@@ -18,6 +18,17 @@ pub enum CalcMode {
Manual,
}
/// Options › Advanced › "For objects, show" (`<workbookPr showObjects>`, ECMA-376 `ST_Objects`):
/// whether charts, pictures, shapes and slicers are drawn. Ctrl+6 switches between `All` and
/// `None`; `Placeholders` only comes from files and is kept as it is.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum ShowObjects {
#[default]
All,
Placeholders,
None,
}
/// File › Options › Formulas › Calculation options, stored per workbook (`<calcPr>`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CalcSettings {
@@ -93,6 +104,8 @@ pub struct Workbook {
pub theme: Theme,
pub date_system: DateSystem,
pub calc: CalcSettings,
#[serde(default, skip_serializing_if = "is_all")]
pub show_objects: ShowObjects,
pub active_sheet: usize,
pub props: DocProps,
/// Workbook structure protection.
@@ -129,6 +142,10 @@ pub struct Workbook {
pub source_path: Option<std::path::PathBuf>,
}
fn is_all(s: &ShowObjects) -> bool {
*s == ShowObjects::All
}
impl Default for Workbook {
fn default() -> Self {
Workbook::new()
@@ -145,6 +162,7 @@ impl Workbook {
theme: Theme::default(),
date_system: DateSystem::D1900,
calc: CalcSettings::default(),
show_objects: ShowObjects::All,
active_sheet: 0,
props: DocProps::default(),
protected_structure: false,
+13 -2
View File
@@ -258,8 +258,17 @@ pub(crate) struct PaintedObject {
pub rect: Rect,
}
/// Ctrl+6 (Options › Advanced › "For objects, show: Nothing"): objects are neither drawn nor
/// clickable. Placeholders, which Excel no longer offers, show the objects.
pub(crate) fn objects_hidden(wb: &Workbook) -> bool {
wb.show_objects == gridcraft_engine::model::ShowObjects::None
}
pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet) -> Vec<PaintedObject> {
let mut painted = Vec::new();
if objects_hidden(wb) {
return painted;
}
let mut done = |start: egui::layers::ShapeIdx, rect: Rect| painted.push(PaintedObject { shapes: start.0..p.add(egui::Shape::Noop).0, rect });
for sp in &sh.shapes {
let r = anchor_rect(geo, sh, &sp.anchor);
@@ -472,7 +481,7 @@ pub enum SlicerAction {
/// A click on a slicer's value tile or clear button, if any. A click elsewhere in a slicer returns
/// `None` so the caller can treat it as a select/drag on the object.
pub fn slicer_click(geo: &Geo, sh: &Sheet, wb: &Workbook, si: usize, p: Pos2) -> Option<(u32, SlicerAction)> {
if !geo.cells.contains(p) {
if !geo.cells.contains(p) || objects_hidden(wb) {
return None;
}
for sl in sh.slicers.iter().rev() {
@@ -506,7 +515,9 @@ fn selection_frame(p: &Painter, r: Rect) {
/// The topmost object under `p`: (kind, id, rect).
pub fn hit(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(&'static str, u32, Rect)> {
let _ = app;
if app.session.active().is_some_and(|d| objects_hidden(&d.wb)) {
return None;
}
for sl in sh.slicers.iter().rev() {
let r = anchor_rect(geo, sh, &sl.anchor);
if r.contains(p) && geo.cells.contains(p) {
+5 -1
View File
@@ -745,7 +745,11 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
} else {
paint_panes(app, &painter, &geo, &wb, si, sh, &sel, outline, &mut cf, &t)
};
// Objects above cells.
// Objects above cells. A hidden object can't stay selected: Delete or the Format pane would act
// on something the user can't see.
if crate::chartview::objects_hidden(&wb) {
app.selected_chart = None;
}
let objects = 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 {
+2
View File
@@ -92,6 +92,8 @@ pub fn lookup(key: Key, physical: Option<Key>, m: Modifiers, mac: bool) -> Optio
Num3 if ctrl && !mac => run("home.italic"),
Num4 if ctrl && !mac => run("home.underline"),
Num5 if ctrl && !mac => run("home.strikethrough"),
// Ctrl+6 hides and shows objects (Windows, and Control+6 in Excel for Mac).
Num6 if ctrl && !s => run("view.showObjects"),
// Letters that differ between Windows and Mac.
L if (cmd || ctrl) && s => run("data.filter"),
L if cmd && mac => run("home.alignLeft"),
@@ -214,3 +214,17 @@ fn alt_down_only_opens_enabled_table_filter_buttons() {
assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "B1");
}
}
#[test]
fn ctrl_6_is_show_objects_and_nothing_else() {
let show = Some(Shortcut::Run("view.showObjects", json!({})));
let mac_control = Modifiers::CTRL;
assert_eq!(lookup(Key::Num6, Some(Key::Num6), CTRL, false), show);
assert_eq!(lookup(Key::Num6, Some(Key::Num6), mac_control, true), show);
// Its neighbours keep theirs: Ctrl+Shift+6 (Ctrl+^) is Scientific, Cmd+6 on a Mac is free.
assert_eq!(lookup(Key::Num6, Some(Key::Num6), CTRL_SHIFT, false), Some(Shortcut::Run("home.numberFormat", json!({"code": "0.00E+00"}))));
assert_eq!(lookup(Key::Num6, Some(Key::Num6), Modifiers::MAC_CMD, true), None);
// No other command claims Ctrl+6.
let claims: Vec<&str> = Session::new().commands().iter().filter(|c| c.shortcut == Some("Ctrl+6")).map(|c| c.id).collect();
assert_eq!(claims, ["view.showObjects"]);
}
+96
View File
@@ -0,0 +1,96 @@
//! Ctrl+6 hides and shows every object (Options › Advanced › "For objects, show"), through real input.
use egui::{Color32, Event, Key, Modifiers, PointerButton, epaint::Shape, vec2};
use gridcraft_engine::Session;
use gridcraft_engine::core::CellRef;
use gridcraft_engine::model::ShowObjects;
use gridcraft_ui_egui::{SheetApp, grid::Geo};
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
const FILL: Color32 = Color32::from_rgb(0x12, 0x34, 0x56);
/// Ctrl on Windows and Linux sets both `ctrl` and `command`.
const CTRL: Modifiers = Modifiers { alt: false, ctrl: true, shift: false, mac_cmd: false, command: true };
/// A rectangle at D3, 144 × 96 points, filled with a colour nothing else on screen uses.
fn fixture() -> (Harness, u32) {
let mut session = Session::new();
session.new_workbook();
let shape = session.execute("insert.shape", json!({"kind": "rectangle", "at": "D3", "fill": "123456"})).unwrap();
let id = shape["shape"].as_u64().unwrap() as u32;
session.execute("selection.set", json!({"range": "A1"})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).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, id)
}
fn ctrl_6(h: &mut Harness) {
h.input_mut().events.push(Event::Key { key: Key::Num6, physical_key: Some(Key::Num6), pressed: true, repeat: false, modifiers: CTRL });
h.input_mut().events.push(Event::Key { key: Key::Num6, physical_key: Some(Key::Num6), pressed: false, repeat: false, modifiers: CTRL });
h.run_steps(2);
}
fn show_objects(h: &Harness) -> ShowObjects {
h.state().session.active().unwrap().wb.show_objects
}
/// Whether the last frame painted the rectangle's fill.
fn painted(h: &Harness) -> bool {
h.output().shapes.iter().any(|c| matches!(&c.shape, Shape::Rect(r) if r.fill == FILL))
}
/// Inside the rectangle, in the cell E4 (D3 is 64 points wide and 20 high at 100%).
fn inside(h: &Harness) -> egui::Pos2 {
let app = h.state();
let sh = app.session.active().unwrap().wb.active().unwrap();
let geo = Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll);
geo.cell_rect(sh, CellRef::parse("E4").unwrap()).center()
}
fn click(h: &mut Harness, pos: egui::Pos2) {
h.input_mut().events.push(Event::PointerMoved(pos));
h.run_steps(2);
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.run_steps(2);
}
}
fn active_cell(h: &Harness) -> CellRef {
h.state().session.active().unwrap().selection.active
}
#[test]
fn ctrl_6_hides_objects_and_a_second_ctrl_6_shows_them() {
let (mut h, _) = fixture();
assert_eq!(show_objects(&h), ShowObjects::All);
assert!(painted(&h), "the rectangle is drawn to begin with");
ctrl_6(&mut h);
assert_eq!(show_objects(&h), ShowObjects::None);
assert!(!painted(&h), "hidden objects are not drawn");
ctrl_6(&mut h);
assert_eq!(show_objects(&h), ShowObjects::All);
assert!(painted(&h), "a second Ctrl+6 draws them again");
}
#[test]
fn hidden_objects_do_not_take_clicks() {
let (mut h, id) = fixture();
let at = inside(&h);
click(&mut h, at);
assert_eq!(h.state().selected_chart, Some(id), "the click lands on the rectangle while it shows");
assert_eq!(active_cell(&h), CellRef::parse("A1").unwrap());
ctrl_6(&mut h);
assert_eq!(h.state().selected_chart, None, "hiding objects drops the selection on one");
click(&mut h, at);
assert_eq!(h.state().selected_chart, None);
assert_eq!(active_cell(&h), CellRef::parse("E4").unwrap(), "the click selects the cell under the hidden rectangle");
}
+10 -5
View File
@@ -3,7 +3,7 @@
use std::sync::Arc;
use gridcraft_core::{DateSystem, RangeRef};
use gridcraft_model::{CalcMode, CellPicture, DefinedName, Sheet, Style, StyleId, StyleTable, Visibility, Workbook};
use gridcraft_model::{CalcMode, CellPicture, DefinedName, Sheet, ShowObjects, Style, StyleId, StyleTable, Visibility, Workbook};
use crate::package::{Package, Rel};
use crate::styles::{StylesIn, read_styles};
@@ -169,10 +169,15 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> {
}
// Workbook properties.
if let Some(p) = wb_xml.child("workbookPr")
&& p.flag("date1904", false)
{
cx.date_system = DateSystem::D1904;
if let Some(p) = wb_xml.child("workbookPr") {
if p.flag("date1904", false) {
cx.date_system = DateSystem::D1904;
}
wb.show_objects = match p.attr("showObjects") {
Some("none") => ShowObjects::None,
Some("placeholders") => ShowObjects::Placeholders,
_ => ShowObjects::All,
};
}
wb.date_system = cx.date_system;
if let Some(c) = wb_xml.child("calcPr") {
+45
View File
@@ -680,6 +680,51 @@ fn iterative_calculation_settings_roundtrip() {
}
}
#[test]
fn show_objects_roundtrip() {
let saved = |wb: &Workbook| {
let bytes = write_xlsx(wb).unwrap();
let mut pkg = Package::open(&bytes).unwrap();
let book = pkg.read_xml("xl/workbook.xml").unwrap().unwrap();
let attr = book.child("workbookPr").and_then(|p| p.attr("showObjects")).map(str::to_string);
(attr, read_xlsx(&bytes).unwrap().0.show_objects)
};
// Default: nothing written, All read back.
let mut wb = Workbook::new();
assert_eq!(saved(&wb), (None, ShowObjects::All));
// Beside date1904, which shares the element.
wb.date_system = DateSystem::D1904;
for (show, attr) in [(ShowObjects::None, "none"), (ShowObjects::Placeholders, "placeholders")] {
wb.show_objects = show;
assert_eq!(saved(&wb), (Some(attr.to_string()), show));
assert_eq!(read_xlsx(&write_xlsx(&wb).unwrap()).unwrap().0.date_system, DateSystem::D1904);
}
// A file written elsewhere: the attribute among the others Excel writes, or absent.
let book = |pr: &str| {
let wbx = format!(
r#"<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">{pr}<sheets><sheet name="A" sheetId="1" r:id="rId1"/></sheets></workbook>"#
);
let rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/></Relationships>"#;
let ws = r#"<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData/></worksheet>"#;
let bytes = super::make_zip(&[
("[Content_Types].xml", super::CT.as_bytes()),
("_rels/.rels", super::ROOT_RELS.as_bytes()),
("xl/workbook.xml", wbx.as_bytes()),
("xl/_rels/workbook.xml.rels", rels.as_bytes()),
("xl/worksheets/sheet1.xml", ws.as_bytes()),
]);
read_xlsx(&bytes).unwrap().0
};
let hidden = book(r#"<workbookPr showObjects="none" defaultThemeVersion="166925"/>"#);
assert_eq!(hidden.show_objects, ShowObjects::None);
assert_eq!(saved(&hidden).0.as_deref(), Some("none"));
for pr in [r#"<workbookPr defaultThemeVersion="166925"/>"#, "", r#"<workbookPr showObjects="bogus"/>"#] {
let wb = book(pr);
assert_eq!(wb.show_objects, ShowObjects::All, "{pr}");
assert_eq!(saved(&wb).0, None, "{pr}: a file without the attribute stays without it");
}
}
#[test]
fn empty_workbook_roundtrip() {
let wb = Workbook::new();
+11 -2
View File
@@ -5,7 +5,7 @@ use std::fmt::Write as _;
use gridcraft_core::RangeRef;
use gridcraft_formula::quote_sheet;
use gridcraft_model::{CalcMode, CalcSettings, Style, Visibility, Workbook};
use gridcraft_model::{CalcMode, CalcSettings, ShowObjects, Style, Visibility, Workbook};
use crate::package::ZipOut;
use crate::xml::{esc, esc_attr};
@@ -329,7 +329,16 @@ pub fn write_xlsx(wb: &Workbook, kind: XlsxKind) -> Result<Vec<u8>, IoError> {
let mut w = format!("{XML_DECL}<workbook xmlns=\"{NS_MAIN}\" xmlns:r=\"{NS_REL}\">");
w.push_str(&crate::passthrough::workbook_elements(wb, "fileVersion"));
w.push_str(if wb.date_system == gridcraft_core::DateSystem::D1904 { "<workbookPr date1904=\"1\"/>" } else { "<workbookPr/>" });
w.push_str("<workbookPr");
match wb.show_objects {
ShowObjects::All => {}
ShowObjects::Placeholders => w.push_str(" showObjects=\"placeholders\""),
ShowObjects::None => w.push_str(" showObjects=\"none\""),
}
if wb.date_system == gridcraft_core::DateSystem::D1904 {
w.push_str(" date1904=\"1\"");
}
w.push_str("/>");
if wb.protected_structure {
let pw = wb.structure_password.as_ref().map(|h| crate::sheet_write::password_attrs(h, "workbook")).unwrap_or_default();
let _ = write!(w, "<workbookProtection{pw} lockStructure=\"1\"/>");