From 33abe3a9f78a6d9b72b3444e5272e895b025505b Mon Sep 17 00:00:00 2001 From: vangel76 Date: Wed, 14 Oct 2026 22:57:29 +0200 Subject: [PATCH 1/2] UI: Protect Sheet entries become Unprotect Sheet on a protected sheet (#1346) Home > Format, HOP and RPS always opened the Protect Sheet dialog, which re-protected the sheet. open_dialog now turns protectSheet into unprotectSheet for a protected sheet, and unprotects at once when there is no password. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/src/i18n/es.rs | 1 + crates/ui-egui/src/i18n/fr.rs | 1 + crates/ui-egui/src/i18n/ko.rs | 1 + crates/ui-egui/src/i18n/pt.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/sr.rs | 1 + crates/ui-egui/src/i18n/zh.rs | 1 + crates/ui-egui/src/lib.rs | 20 ++++++++++++++++++++ crates/ui-egui/src/ribbon.rs | 9 +++------ crates/ui-egui/src/tabs.rs | 4 ++-- crates/ui-egui/tests/protect_password.rs | 19 +++++++++++++++++++ 14 files changed, 54 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..e30d8106b 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) (#1346, part 1) | | 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/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 c20c7d1b3..50330f384 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 5d20ab0ca..24848089c 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 cbf18d4c4..118f703d5 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 f96cdac3c..2d78f96b5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -708,11 +708,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 9c6d89eeb..b35289a82 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -921,6 +921,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, @@ -942,7 +943,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!({})), ], @@ -2082,11 +2083,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 b77524bb9..3dfae5be0 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -199,9 +199,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()); +} From 0d18137114fd841ff82811191042681a6e647700 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Wed, 14 Oct 2026 23:00:50 +0200 Subject: [PATCH 2/2] Protection: enforce selectLockedCells/selectUnlockedCells and offer them in Protect Sheet (#1346) selection.set refuses a cell the protection forbids selecting, selection.move steps on to the next selectable cell, and review.protectSheet keeps the sheet's current selection rights unless told otherwise. The dialog gains the two select options and insert/delete columns. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 2 +- crates/engine/src/cmd/edit.rs | 32 ++++++++++++++++++++++++++++++++ crates/engine/src/cmd/review.rs | 28 ++++++++++++++++++++++++++-- crates/ui-egui/src/dialogs.rs | 6 +++++- 4 files changed, 64 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e30d8106b..ed706403c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,7 +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) (#1346, part 1) | +| 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 82dbe0be7..ce926de0e 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -362,6 +362,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)?; @@ -379,6 +385,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()})) @@ -447,6 +456,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);