diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index 187c793b..50d79b9c 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -1,6 +1,6 @@ //! Selection state (objects and, for direct selection, anchors and segments; ruler guides). -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; use serde::{Deserialize, Serialize}; @@ -165,9 +165,17 @@ impl Selection { /// around them (and so the key object when it is one): hidden or locked art can't stay /// selected. pub fn deselect_uneditable(&mut self, doc: &Document) { - let out: Vec = self.objects.iter().copied().filter(|o| !doc.is_editable(*o)).collect(); - for o in out { - self.remove(o); + let uneditable: HashSet = self.objects.iter().copied().filter(|o| !doc.is_editable(*o)).collect(); + if uneditable.is_empty() { + return; + } + self.target = None; + self.slices.clear(); + self.objects.retain(|o| !uneditable.contains(o)); + self.anchors.retain(|id, _| !uneditable.contains(id)); + self.segments.retain(|id, _| !uneditable.contains(id)); + if self.key.is_some_and(|k| uneditable.contains(&k)) { + self.key = None; } } /// Target `id` (see [`Selection::target`]): a layer gets its visible, unlocked art selected @@ -208,134 +216,4 @@ impl Selection { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn basic_ops() { - let mut s = Selection::default(); - s.add(NodeId(1)); - s.add(NodeId(1)); - s.toggle(NodeId(2)); - assert_eq!(s.objects, vec![NodeId(1), NodeId(2)]); - s.toggle(NodeId(1)); - assert_eq!(s.objects, vec![NodeId(2)]); - s.clear(); - assert!(s.is_empty()); - } - - #[test] - fn guides_are_selected_on_their_own() { - let mut s = Selection::default(); - s.add(NodeId(1)); - s.set_guides([2, 0, 2]); - assert_eq!((s.objects.clone(), s.guides.clone()), (vec![], vec![2, 0])); - assert!(s.has_objects_or_guides() && s.is_empty()); - // Shift-adding an object keeps them; a new selection doesn't. - s.add(NodeId(1)); - assert_eq!(s.guides, vec![2, 0]); - s.set([NodeId(1)]); - assert!(s.guides.is_empty()); - // Toggled, a guide joins (or leaves) the selected objects. - s.toggle_guide(0); - assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![0])); - s.toggle_guide(0); - assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![])); - // Pruning drops guides the document no longer has. - let mut d = Document::new(100.0, 100.0); - d.guides.push(crate::Guide::new(true, 10.0)); - s.set_guides([0, 1]); - s.prune(&d); - assert_eq!(s.guides, vec![0]); - } - - #[test] - fn guides_show_and_lock_with_their_layer() { - let mut d = Document::new(100.0, 100.0); - let layer = d.layers[0].id; - let g = crate::Guide { layer: Some(layer), ..crate::Guide::new(true, 10.0) }; - assert!(d.guide_shown(&g) && d.guide_editable(&g)); - d.node_mut(layer).unwrap().locked = true; - assert!(d.guide_shown(&g) && !d.guide_editable(&g), "locked: shown, not editable"); - d.node_mut(layer).unwrap().visible = false; - assert!(!d.guide_shown(&g) && !d.guide_editable(&g), "hidden"); - // On no layer, or a layer that's gone: always there. - assert!(d.guide_shown(&crate::Guide::new(false, 5.0))); - let gone = crate::Guide { layer: Some(NodeId(9999)), ..crate::Guide::new(true, 1.0) }; - assert!(d.guide_shown(&gone) && d.guide_editable(&gone)); - d.guides.push(g); - assert_eq!(d.guides_on(layer).count(), 1); - // Guides on no layer, or a gone one, are adopted by the top visible, unlocked layer. - d.node_mut(layer).unwrap().visible = true; - d.node_mut(layer).unwrap().locked = false; - d.guides.push(crate::Guide::new(false, 5.0)); - d.guides.push(gone); - assert_eq!(d.adopt_guides(), 2); - assert!(d.guides.iter().all(|g| g.layer == Some(layer))); - assert_eq!(d.adopt_guides(), 0, "once"); - // A guide saved before guides had layers reads as one on no layer. - let old: crate::Guide = serde_json::from_str(r#"{"vertical": true, "pos": 5.0}"#).unwrap(); - assert_eq!(old.layer, None); - assert!(!serde_json::to_string(&crate::Guide::new(true, 5.0)).unwrap().contains("layer"), "nothing new written"); - } - - #[test] - fn selection_changes_clear_the_target() { - let mut s = Selection::default(); - s.set([NodeId(2), NodeId(3)]); - s.target = Some(NodeId(1)); - assert_eq!(s.subjects(), &[NodeId(1)]); - s.add(NodeId(4)); - assert_eq!(s.target, None); - assert_eq!(s.subjects(), &[NodeId(2), NodeId(3), NodeId(4)]); - s.target = Some(NodeId(1)); - s.remove(NodeId(2)); - assert_eq!(s.target, None); - s.target = Some(NodeId(1)); - s.set([NodeId(3)]); - assert_eq!(s.target, None); - } - - #[test] - fn a_key_object_needs_two_or_more_selected_objects() { - let mut s = Selection::default(); - s.set([NodeId(1), NodeId(2)]); - s.key = Some(NodeId(2)); - s.remove(NodeId(1)); - assert_eq!(s.key, Some(NodeId(2)), "another object left: remove keeps the key"); - s.remove(NodeId(2)); - assert_eq!(s.key, None, "the key object itself left"); - // Pruning drops objects the document no longer has and clears the key once no other - // selected object lies outside it. - let mut d = Document::new(100.0, 100.0); - let layer = d.layers[0].id; - let mut ids = vec![]; - for _ in 0..4 { - let id = d.alloc_id(); - ids.push(d.insert(Some(layer), usize::MAX, crate::Node::group(id, vec![])).unwrap()); - } - s.set(ids.iter().copied()); - s.key = Some(ids[3]); - d.remove(ids[0]).unwrap(); - s.prune(&d); - assert_eq!(s.key, Some(ids[3]), "three objects left: the key stays"); - s.toggle(ids[1]); - s.prune(&d); - assert_eq!(s.key, Some(ids[3]), "two objects left: the key stays"); - s.toggle(ids[2]); - s.prune(&d); - assert_eq!((s.objects.clone(), s.key), (vec![ids[3]], None), "one object left"); - // A group key selected with only its own member is cleared too; another selected object - // outside the group keeps it. - let member = d.alloc_id(); - d.insert(Some(ids[3]), usize::MAX, crate::Node::group(member, vec![])).unwrap(); - s.set([member, ids[3]]); - s.key = Some(ids[3]); - s.prune(&d); - assert_eq!(s.key, None, "a group and its own member"); - s.set([member, ids[3], ids[2]]); - s.key = Some(ids[3]); - s.prune(&d); - assert_eq!(s.key, Some(ids[3]), "another object outside the group"); - } -} +mod tests; diff --git a/crates/doc/src/selection/tests.rs b/crates/doc/src/selection/tests.rs new file mode 100644 index 00000000..5665f114 --- /dev/null +++ b/crates/doc/src/selection/tests.rs @@ -0,0 +1,219 @@ +use std::sync::Arc; + +use super::*; +use crate::Node; + +#[test] +fn basic_ops() { + let mut s = Selection::default(); + s.add(NodeId(1)); + s.add(NodeId(1)); + s.toggle(NodeId(2)); + assert_eq!(s.objects, vec![NodeId(1), NodeId(2)]); + s.toggle(NodeId(1)); + assert_eq!(s.objects, vec![NodeId(2)]); + s.clear(); + assert!(s.is_empty()); +} + +#[test] +fn guides_are_selected_on_their_own() { + let mut s = Selection::default(); + s.add(NodeId(1)); + s.set_guides([2, 0, 2]); + assert_eq!((s.objects.clone(), s.guides.clone()), (vec![], vec![2, 0])); + assert!(s.has_objects_or_guides() && s.is_empty()); + // Shift-adding an object keeps them; a new selection doesn't. + s.add(NodeId(1)); + assert_eq!(s.guides, vec![2, 0]); + s.set([NodeId(1)]); + assert!(s.guides.is_empty()); + // Toggled, a guide joins (or leaves) the selected objects. + s.toggle_guide(0); + assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![0])); + s.toggle_guide(0); + assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![])); + // Pruning drops guides the document no longer has. + let mut d = Document::new(100.0, 100.0); + d.guides.push(crate::Guide::new(true, 10.0)); + s.set_guides([0, 1]); + s.prune(&d); + assert_eq!(s.guides, vec![0]); +} + +#[test] +fn guides_show_and_lock_with_their_layer() { + let mut d = Document::new(100.0, 100.0); + let layer = d.layers[0].id; + let g = crate::Guide { layer: Some(layer), ..crate::Guide::new(true, 10.0) }; + assert!(d.guide_shown(&g) && d.guide_editable(&g)); + d.node_mut(layer).unwrap().locked = true; + assert!(d.guide_shown(&g) && !d.guide_editable(&g), "locked: shown, not editable"); + d.node_mut(layer).unwrap().visible = false; + assert!(!d.guide_shown(&g) && !d.guide_editable(&g), "hidden"); + // On no layer, or a layer that's gone: always there. + assert!(d.guide_shown(&crate::Guide::new(false, 5.0))); + let gone = crate::Guide { layer: Some(NodeId(9999)), ..crate::Guide::new(true, 1.0) }; + assert!(d.guide_shown(&gone) && d.guide_editable(&gone)); + d.guides.push(g); + assert_eq!(d.guides_on(layer).count(), 1); + // Guides on no layer, or a gone one, are adopted by the top visible, unlocked layer. + d.node_mut(layer).unwrap().visible = true; + d.node_mut(layer).unwrap().locked = false; + d.guides.push(crate::Guide::new(false, 5.0)); + d.guides.push(gone); + assert_eq!(d.adopt_guides(), 2); + assert!(d.guides.iter().all(|g| g.layer == Some(layer))); + assert_eq!(d.adopt_guides(), 0, "once"); + // A guide saved before guides had layers reads as one on no layer. + let old: crate::Guide = serde_json::from_str(r#"{"vertical": true, "pos": 5.0}"#).unwrap(); + assert_eq!(old.layer, None); + assert!(!serde_json::to_string(&crate::Guide::new(true, 5.0)).unwrap().contains("layer"), "nothing new written"); +} + +#[test] +fn selection_changes_clear_the_target() { + let mut s = Selection::default(); + s.set([NodeId(2), NodeId(3)]); + s.target = Some(NodeId(1)); + assert_eq!(s.subjects(), &[NodeId(1)]); + s.add(NodeId(4)); + assert_eq!(s.target, None); + assert_eq!(s.subjects(), &[NodeId(2), NodeId(3), NodeId(4)]); + s.target = Some(NodeId(1)); + s.remove(NodeId(2)); + assert_eq!(s.target, None); + s.target = Some(NodeId(1)); + s.set([NodeId(3)]); + assert_eq!(s.target, None); +} + +#[test] +fn a_key_object_needs_two_or_more_selected_objects() { + let mut s = Selection::default(); + s.set([NodeId(1), NodeId(2)]); + s.key = Some(NodeId(2)); + s.remove(NodeId(1)); + assert_eq!(s.key, Some(NodeId(2)), "another object left: remove keeps the key"); + s.remove(NodeId(2)); + assert_eq!(s.key, None, "the key object itself left"); + // Pruning drops objects the document no longer has and clears the key once no other + // selected object lies outside it. + let mut d = Document::new(100.0, 100.0); + let layer = d.layers[0].id; + let mut ids = vec![]; + for _ in 0..4 { + let id = d.alloc_id(); + ids.push(d.insert(Some(layer), usize::MAX, crate::Node::group(id, vec![])).unwrap()); + } + s.set(ids.iter().copied()); + s.key = Some(ids[3]); + d.remove(ids[0]).unwrap(); + s.prune(&d); + assert_eq!(s.key, Some(ids[3]), "three objects left: the key stays"); + s.toggle(ids[1]); + s.prune(&d); + assert_eq!(s.key, Some(ids[3]), "two objects left: the key stays"); + s.toggle(ids[2]); + s.prune(&d); + assert_eq!((s.objects.clone(), s.key), (vec![ids[3]], None), "one object left"); + // A group key selected with only its own member is cleared too; another selected object + // outside the group keeps it. + let member = d.alloc_id(); + d.insert(Some(ids[3]), usize::MAX, crate::Node::group(member, vec![])).unwrap(); + s.set([member, ids[3]]); + s.key = Some(ids[3]); + s.prune(&d); + assert_eq!(s.key, None, "a group and its own member"); + s.set([member, ids[3], ids[2]]); + s.key = Some(ids[3]); + s.prune(&d); + assert_eq!(s.key, Some(ids[3]), "another object outside the group"); +} + +fn below(r: &mut u64, n: u64) -> u64 { + *r ^= *r << 13; + *r ^= *r >> 7; + *r ^= *r << 17; + *r % n.max(1) +} + +fn random_group(d: &mut Document, r: &mut u64, depth: u64, ids: &mut Vec) -> Arc { + let id = d.alloc_id(); + ids.push(id); + let count = if depth == 0 { 0 } else { 1 + below(r, 4) }; + let children = (0..count) + .map(|_| { + let depth = below(r, depth); + random_group(d, r, depth, ids) + }) + .collect(); + let mut group = Node::group(id, children); + group.locked = below(r, 6) == 0; + group.visible = below(r, 6) != 0; + Arc::new(group) +} + +fn random_document(r: &mut u64) -> (Document, Vec) { + let mut d = Document::new(100.0, 100.0); + let mut ids = vec![]; + for _ in 0..3 { + let mut layer = Node::layer(d.alloc_id(), "L", crate::LayerColor::Preset(0)); + let children = (0..1 + below(r, 4)).map(|_| random_group(&mut d, r, 3, &mut ids)).collect(); + if let Some(c) = layer.children_mut() { + *c = children; + } + layer.locked = below(r, 6) == 0; + layer.visible = below(r, 6) != 0; + d.layers.push(Arc::new(layer)); + } + (d, ids) +} + +fn random_selection(r: &mut u64, ids: &[NodeId]) -> Selection { + let mut s = Selection::default(); + let missing = (below(r, 2) == 0).then_some(NodeId(u64::MAX)); + s.set(ids.iter().copied().filter(|_| below(r, 2) == 0).chain(missing)); + let k = below(r, s.objects.len() as u64) as usize; + s.objects.rotate_left(k); + for &o in &s.objects { + match below(r, 3) { + 0 => { + s.anchors.insert(o, BTreeSet::from([(0, 0)])); + } + 1 => { + s.anchors.insert(o, BTreeSet::from([(0, 1)])); + s.segments.insert(o, BTreeSet::from([(0, 0)])); + } + _ => {} + } + } + s.key = s.objects.get(below(r, s.objects.len() as u64) as usize).copied(); + s.target = ids.get(below(r, ids.len() as u64) as usize).copied().filter(|_| below(r, 2) == 0); + s.slices = ids.iter().copied().take(below(r, 3) as usize).collect(); + s.guides = vec![1, 0]; + s +} + +#[test] +fn deselect_uneditable_matches_removing_one_at_a_time() { + let (mut unchanged, mut partial) = (0, 0); + for seed in 1..400u64 { + let mut r = seed.wrapping_mul(0x9e37_79b9_7f4a_7c15) | 1; + let (d, mut ids) = random_document(&mut r); + if seed % 4 == 0 { + ids.retain(|id| d.is_editable(*id)); + } + let s = random_selection(&mut r, &ids); + let mut expected = s.clone(); + for o in s.objects.iter().copied().filter(|o| !d.is_editable(*o)) { + expected.remove(o); + } + let mut actual = s.clone(); + actual.deselect_uneditable(&d); + assert_eq!(actual, expected, "seed {seed}"); + unchanged += usize::from(actual == s); + partial += usize::from(!actual.objects.is_empty() && actual.objects.len() < s.objects.len()); + } + assert!(unchanged > 10 && partial > 100, "{unchanged} {partial}"); +}