diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..ed706403c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-14 | Protect Sheet on a protected sheet: Home › Format, its keytips (HOP, RPS) and the tab menu now read Unprotect Sheet… and unprotect (straight away without a password); a protected sheet that forbids selecting locked (or unlocked) cells is enforced for clicks, arrows and Go To, and the Protect Sheet dialog has the select options (#1346) | | 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) | | 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | | 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 1de5b8075..edfdce119 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -373,6 +373,12 @@ fn range_fill(s: &mut Session, p: &Json) -> Result { // ---------------------------------------------------------------- selection +/// Whether the cell may be selected: a protected sheet can forbid selecting locked and/or unlocked cells. +fn cell_selectable(wb: &gridcraft_model::Workbook, sheet: &gridcraft_model::Sheet, c: CellRef) -> bool { + let Some(p) = sheet.protection.as_ref() else { return true }; + if wb.styles.get(sheet.style_id(c)).protection.locked { p.select_locked } else { p.select_unlocked } +} + fn selection_set(s: &mut Session, p: &Json) -> Result { if p.get("sheet").is_some() { let i = target_sheet(s, p)?; @@ -390,6 +396,9 @@ fn selection_set(s: &mut Session, p: &Json) -> Result { let mut sel = crate::Selection { active, anchor: ranges.last().map(|r| r.start).unwrap_or(active), ranges }; if let Some(sh) = d.wb.active() { sel.expand_merges(sh); + if !cell_selectable(&d.wb, sh, sel.active) { + return Err(EngineError::Other("The cell you are trying to change is on a protected sheet that does not allow selecting it.".into())); + } } d.selection = sel; Ok(json!({"selection": d.selection.a1(), "active": d.selection.active.a1()})) @@ -458,6 +467,29 @@ fn selection_move(s: &mut Session, p: &Json) -> Result { { to = m.start; } + if !extend && !cell_selectable(&d.wb, sheet, to) { + // Step on to the next selectable cell in the same direction; stay put if there is none. + let (sr, sc) = (dr.signum(), dc.signum()); + let mut t = to; + let mut found = false; + if !jmp && (sr, sc) != (0, 0) { + for _ in 0..10_000 { + let n = t.offset_clamped(sr, sc); + if n == t { + break; + } + t = n; + if !sheet.is_row_hidden(t.row) && !sheet.is_col_hidden(t.col) && cell_selectable(&d.wb, sheet, t) { + found = true; + break; + } + } + } + if !found { + return Ok(json!({"selection": sel.a1(), "active": sel.active.a1()})); + } + to = t; + } if extend { sel.extend_to(to); sel.expand_merges(sheet); diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index c55aac8e5..bd82d7c4f 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -139,11 +139,15 @@ fn protect_sheet(s: &mut Session, p: &Json) -> Result { return Err(EngineError::Other("The sheet is protected with a password. Unprotect it first (Review › Unprotect Sheet).".into())); } let b = |k: &str| bool_param(p, k).unwrap_or(false); + // Selection rights default to what the sheet has now (a file may forbid selecting locked cells). + let current = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.protection.clone()); + let select_locked = bool_param(p, "selectLockedCells").unwrap_or_else(|| current.as_ref().is_none_or(|c| c.select_locked)); + let select_unlocked = bool_param(p, "selectUnlockedCells").unwrap_or_else(|| current.as_ref().is_none_or(|c| c.select_unlocked)); let prot = SheetProtection { password: None, legacy_password_hash: None, - select_locked: true, - select_unlocked: true, + select_locked, + select_unlocked, format_cells: b("formatCells"), format_columns: b("formatColumns"), format_rows: b("formatRows"), @@ -399,6 +403,26 @@ mod tests { assert_eq!(at(&s), (0, "A1".to_string())); } + #[test] + fn protection_that_forbids_selecting_locked_cells_is_enforced_and_kept() { + let mut s = book(); + s.execute("home.lockCell", json!({"range": "B2", "on": false})).unwrap(); + s.execute("review.protectSheet", json!({"selectLockedCells": false})).unwrap(); + // Locked cells can't be selected; unlocked ones can. + assert!(s.execute("selection.set", json!({"cell": "A1"})).is_err()); + s.execute("selection.set", json!({"cell": "B2"})).unwrap(); + // Arrow keys skip locked cells, and stay put when nothing selectable is left. + s.execute("selection.move", json!({"dr": 0, "dc": 1})).unwrap(); + assert_eq!(s.doc().unwrap().selection.active.a1(), "B2"); + // Re-protecting without the flag keeps the sheet's selection rights. + s.execute("review.unprotectSheet", json!({})).unwrap(); + s.execute("review.protectSheet", json!({"selectLockedCells": false})).unwrap(); + s.execute("review.protectSheet", json!({"sort": true})).unwrap(); + assert!(!s.doc().unwrap().wb.active().unwrap().protection.clone().unwrap().select_locked); + s.execute("review.unprotectSheet", json!({})).unwrap(); + s.execute("selection.set", json!({"cell": "A1"})).unwrap(); + } + #[test] fn sheet_passwords_use_excel_hashes_and_old_json_hashes_still_verify() { let mut s = book(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index fb899c702..00a488cf3 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -182,15 +182,19 @@ impl Dialog { vec![ Password { key: "password", label: "Password (optional):" }, Note("Allow all users of this sheet to:".into()), + Check { key: "selectLockedCells", label: "Select locked cells" }, + Check { key: "selectUnlockedCells", label: "Select unlocked cells" }, Check { key: "formatCells", label: "Format cells" }, Check { key: "formatColumns", label: "Format columns" }, Check { key: "formatRows", label: "Format rows" }, + Check { key: "insertColumns", label: "Insert columns" }, Check { key: "insertRows", label: "Insert rows" }, + Check { key: "deleteColumns", label: "Delete columns" }, Check { key: "deleteRows", label: "Delete rows" }, Check { key: "sort", label: "Sort" }, Check { key: "autofilter", label: "Use AutoFilter" }, ], - json!({}), + json!({"selectLockedCells": true, "selectUnlockedCells": true}), ), "allowEditRanges" => { let list = app.session.run("review.allowEditRanges", json!({})).unwrap_or(Json::Null); diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 7ed8372c7..9f2beee95 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -184,6 +184,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Protect\nWorkbook" => "Zamknout\nsešit", "Unprotect\nSheet" => "Odemknout\nlist", "Protect Sheet…" => "Zamknout list…", + "Unprotect Sheet…" => "Odemknout list…", "Comments" => "Komentáře", "Notes" => "Poznámky", "Page Break\nPreview" => "Náhled\nkonců stránek", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 371ea7981..d6e2aa879 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -184,6 +184,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Protect\nWorkbook" => "Arbeitsmappe\nschützen", "Unprotect\nSheet" => "Blattschutz\naufheben", "Protect Sheet…" => "Blatt schützen…", + "Unprotect Sheet…" => "Blattschutz aufheben…", "Comments" => "Kommentare", "Notes" => "Notizen", "Page Break\nPreview" => "Seitenumbruch-\nvorschau", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 4ce231bca..65b3d7f18 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -185,6 +185,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Protect\nWorkbook" => "Proteger\nlibro", "Unprotect\nSheet" => "Desproteger\nhoja", "Protect Sheet…" => "Proteger hoja…", + "Unprotect Sheet…" => "Desproteger hoja…", "Comments" => "Comentarios", "Notes" => "Notas", "Page Break\nPreview" => "Vista previa de\nsalto de página", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 82027e847..accb2126e 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -206,6 +206,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Allow Edit Ranges" => "Autoriser la modification des plages", "Unprotect\nSheet" => "Ôter la protection\nde la feuille", "Protect Sheet…" => "Protéger la feuille…", + "Unprotect Sheet…" => "Ôter la protection de la feuille…", "Comments" => "Commentaires", "Notes" => "Notes", "Page Break\nPreview" => "Aperçu des sauts\nde page", diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index 1902ad301..f98ae51a3 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -203,6 +203,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Allow Edit Ranges" => "범위 편집 허용", "Unprotect\nSheet" => "시트 보호\n해제", "Protect Sheet…" => "시트 보호…", + "Unprotect Sheet…" => "시트 보호 해제…", "Comments" => "댓글", "Notes" => "메모", "Page Break\nPreview" => "페이지 나누기\n미리 보기", diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index fb9236cdb..b4f65da54 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -204,6 +204,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Allow Edit Ranges" => "Permitir Edição de Intervalos", "Unprotect\nSheet" => "Desproteger\nPlanilha", "Protect Sheet…" => "Proteger Planilha…", + "Unprotect Sheet…" => "Desproteger Planilha…", "Comments" => "Comentários", "Notes" => "Notas", "Page Break\nPreview" => "Visualização\nde Quebras de Página", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 765f6bb32..90333030d 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -203,6 +203,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Allow Edit Ranges" => "Разрешить изменение диапазонов", "Unprotect\nSheet" => "Снять защиту\nлиста", "Protect Sheet…" => "Защитить лист…", + "Unprotect Sheet…" => "Снять защиту листа…", "Comments" => "Комментарии", "Notes" => "Заметки", "Page Break\nPreview" => "Предварительный просмотр\nразрывов страниц", diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index 85bd2da72..bda75428b 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -198,6 +198,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Allow Edit Ranges" => "Dozvoli uređivanje opsega", "Unprotect\nSheet" => "Ukloni zaštitu\nlista", "Protect Sheet…" => "Zaštiti list…", + "Unprotect Sheet…" => "Ukloni zaštitu lista…", "Comments" => "Komentari", "Notes" => "Beleške", "Page Break\nPreview" => "Pregled preloma\nstranica", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 00175925e..41c313e83 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -203,6 +203,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Allow Edit Ranges" => "允许编辑区域", "Unprotect\nSheet" => "撤销\n工作表保护", "Protect Sheet…" => "保护工作表…", + "Unprotect Sheet…" => "撤销工作表保护…", "Comments" => "批注", "Notes" => "备注", "Page Break\nPreview" => "分页符\n预览", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 589c4ca14..532227d1b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -731,11 +731,31 @@ impl SheetApp { } } + /// Whether the active sheet of the active document is protected (drives the Protect/Unprotect + /// Sheet labels and actions). + pub fn active_sheet_protected(&self) -> bool { + self.session.active().and_then(|d| d.wb.active().map(|s| s.is_protected())).unwrap_or(false) + } + pub fn open_dialog(&mut self, name: &str, params: Json) { if let Err(e) = self.commit_text_box_edit() { self.message = Some(("Text Box".into(), clean_error(&e))); return; } + // Every Protect Sheet entry point (ribbon menus, keytips, tab menu) becomes Unprotect Sheet + // on a protected sheet. A sheet without a password is unprotected straight away. + let name = if name == "protectSheet" && self.active_sheet_protected() { "unprotectSheet" } else { name }; + if name == "unprotectSheet" + && self + .session + .active() + .and_then(|d| d.wb.active()) + .and_then(|s| s.protection.as_ref()) + .is_some_and(|p| p.password.is_none() && p.legacy_password_hash.is_none()) + { + self.run_or_alert("review.unprotectSheet", json!({})); + return; + } match name { "open" => { if let Some(start) = &self.services.open_async { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 5b46a07c8..fa683a1f8 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -924,6 +924,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { if format_keys { format_popup = format_popup.open_memory(egui::SetOpenCommand::Bool(true)); } + let protect_label = if app.active_sheet_protected() { "Unprotect Sheet…" } else { "Protect Sheet…" }; format_popup.show(|ui| { menu_items( app, @@ -945,7 +946,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) { ("Rename Sheet", "dialog:renameSheet", json!({})), ("Move or Copy Sheet…", "dialog:moveSheet", json!({})), ("-", "", json!(null)), - ("Protect Sheet…", "dialog:protectSheet", json!({})), + (protect_label, "dialog:protectSheet", json!({})), ("Lock Cell", "home.lockCell", json!({})), ("Format Cells…", "dialog:formatCells", json!({})), ], @@ -2085,11 +2086,7 @@ fn review(app: &mut SheetApp, ui: &mut Ui) { let __k = big_button(ui, Icon::Lock, if protected { "Unprotect\nSheet" } else { "Protect\nSheet" }, "Protect Sheet", false); control_keytip(app, ui, &__k, "dialog:protectSheet"); if __k.clicked() { - if protected { - app.open_dialog("unprotectSheet", json!({})); - } else { - app.open_dialog("protectSheet", json!({})); - } + app.open_dialog("protectSheet", json!({})); } let __k = big_button(ui, Icon::Book, "Protect\nWorkbook", "Protect Workbook structure", false); control_keytip(app, ui, &__k, "review.protectWorkbook"); diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index ddb083948..efeb422a4 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -201,9 +201,9 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { app.run_or_alert("sheet.unhide", json!({})); } ui.separator(); - let protected = app.session.active().and_then(|d| d.wb.active().map(|s| s.is_protected())).unwrap_or(false); + let protected = app.active_sheet_protected(); if ui.button(if protected { "Unprotect Sheet…" } else { "Protect Sheet…" }).clicked() { - app.open_dialog(if protected { "unprotectSheet" } else { "protectSheet" }, json!({})); + app.open_dialog("protectSheet", json!({})); } if ui.button("Select All Sheets").clicked() { ui.close(); diff --git a/crates/ui-egui/tests/protect_password.rs b/crates/ui-egui/tests/protect_password.rs index ee68cf2b6..7a2fe551e 100644 --- a/crates/ui-egui/tests/protect_password.rs +++ b/crates/ui-egui/tests/protect_password.rs @@ -68,3 +68,22 @@ fn protect_and_unprotect_sheet_mask_the_password() { h.run_steps(3); assert!(h.state().session.active().unwrap().wb.active().unwrap().protection.is_none()); } + +#[test] +fn protect_sheet_entry_unprotects_a_protected_sheet() { + // No password: the Protect Sheet entry points (Home > Format, HOP, RPS, tab menu) unprotect straight away. + let mut h = harness(); + h.state_mut().run("review.protectSheet", json!({})).unwrap(); + assert!(h.state().active_sheet_protected()); + h.state_mut().open_dialog("protectSheet", json!({})); + h.run_steps(3); + assert!(h.state().dialog.is_none(), "no password: no dialog"); + assert!(!h.state().active_sheet_protected()); + + // With a password the Unprotect dialog asks for it, and a wrong one leaves the sheet protected. + h.state_mut().run("review.protectSheet", json!({"password": SECRET})).unwrap(); + h.state_mut().open_dialog("protectSheet", json!({})); + h.run_steps(3); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("unprotectSheet")); + assert!(h.state().active_sheet_protected()); +}