diff --git a/crates/engine/src/cmd/edit/selection.rs b/crates/engine/src/cmd/edit/selection.rs index 03e178156..27db1c53a 100644 --- a/crates/engine/src/cmd/edit/selection.rs +++ b/crates/engine/src/cmd/edit/selection.rs @@ -139,7 +139,13 @@ pub(super) fn selection_next(s: &mut Session, p: &Json) -> Result { pub(super) fn selection_next_corner(s: &mut Session, _: &Json) -> Result { let d = s.doc_mut()?; - d.selection.next_corner(); + let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) }; + // A corner in a merged cell lands on its top-left cell; one the sheet's protection doesn't + // let you select is skipped. + let stop = |c: CellRef| Some(sheet.merge_at(c).map_or(c, |m| m.start)).filter(|c| cell_selectable(&d.wb, sheet, *c)); + let mut next = d.selection.clone(); + next.next_corner_by(stop); + d.selection = next; Ok(json!({"active": d.selection.active.a1(), "selection": d.selection.a1()})) } diff --git a/crates/engine/src/cmd/edit/tests.rs b/crates/engine/src/cmd/edit/tests.rs index ead610e50..8e8c52b36 100644 --- a/crates/engine/src/cmd/edit/tests.rs +++ b/crates/engine/src/cmd/edit/tests.rs @@ -397,3 +397,30 @@ fn next_corner_of_selection_goes_clockwise_and_anchors_there() { s.execute("selection.set", json!({"range": "C5"})).unwrap(); assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "C5"); } + +#[test] +fn next_corner_skips_corners_the_sheet_protection_does_not_let_you_select() { + // With locked cells unselectable, Ctrl+. goes on to the next corner it may select. + let mut s = Session::new(); + s.new_workbook(); + s.execute("home.lockCell", json!({"range": "B2:C3", "on": false})).unwrap(); + s.execute("selection.set", json!({"range": "B2:D3", "active": "B2"})).unwrap(); + s.execute("review.protectSheet", json!({"selectLockedCells": false})).unwrap(); + let r = s.execute("selection.nextCorner", json!({})).unwrap(); + assert_eq!((r["active"].as_str(), r["selection"].as_str()), (Some("B3"), Some("B2:D3")), "D2 and D3 are locked"); + assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "B2"); +} + +#[test] +fn next_corner_stops_once_on_a_merged_corner_at_its_top_left_cell() { + // A corner inside a merged cell is that merged cell, whose active cell is its top-left one. + let mut s = Session::new(); + s.new_workbook(); + s.execute("home.mergeCells", json!({"range": "C2:D2"})).unwrap(); + s.execute("selection.set", json!({"range": "B2:D3", "active": "B2"})).unwrap(); + let seen: Vec = (0..4).map(|_| s.execute("selection.nextCorner", json!({})).unwrap()["active"].as_str().unwrap().to_string()).collect(); + assert_eq!(seen, ["C2", "D3", "B3", "B2"]); + // A selection that is one merged cell has no other corner. + s.execute("selection.set", json!({"range": "C2:D2", "active": "C2"})).unwrap(); + assert_eq!(s.execute("selection.nextCorner", json!({})).unwrap()["active"], "C2"); +} diff --git a/crates/engine/src/selection.rs b/crates/engine/src/selection.rs index a69870adb..3f3feee9d 100644 --- a/crates/engine/src/selection.rs +++ b/crates/engine/src/selection.rs @@ -134,14 +134,23 @@ impl Selection { /// so Shift+arrows then move the opposite corner. The area stays selected. From a cell that /// isn't a corner the next corner is the top-left one; a single cell stays where it is. pub fn next_corner(&mut self) { + self.next_corner_by(Some); + } + + /// [`Selection::next_corner`] where `stop` says where each corner takes the active cell: the + /// top-left cell of a merged cell the corner is in, or `None` for a corner that can't be + /// selected (it is skipped). Corners that end up on the active cell are one stop with it. + pub fn next_corner_by(&mut self, stop: impl Fn(CellRef) -> Option) { let r = self.current(); let corners = [r.start, CellRef::new(r.start.row, r.end.col), r.end, CellRef::new(r.end.row, r.start.col)]; - let next = match corners.iter().position(|c| *c == self.active) { - Some(i) => (1..=4).filter_map(|k| corners.get((i + k) % 4)).copied().find(|c| *c != self.active).unwrap_or(self.active), - None => r.start, + let next = match corners.iter().rposition(|c| stop(*c) == Some(self.active)) { + Some(i) => (1..=4).filter_map(|k| corners.get((i + k) % 4)).filter_map(|c| stop(*c)).find(|c| *c != self.active), + None => corners.iter().find_map(|c| stop(*c)), }; - self.active = next; - self.anchor = next; + if let Some(next) = next { + self.active = next; + self.anchor = next; + } } }