From fc73fb5b805b8eb192b449c0a88c25c510a73881 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Wed, 21 Oct 2026 12:15:06 -0400 Subject: [PATCH] LRC-LIB-SMARTCOLL: the move engine for reordering rules and groups Pure functions on a RuleSet (judge, move_node, key_dest and their path helpers) with unit tests; nothing calls them yet. The smart album editor's reordering (a separate change) is built on them. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/rules_move.rs | 399 ++++++++++++++++++++++++ 2 files changed, 400 insertions(+) create mode 100644 crates/ui-egui/src/panels/rules_move.rs diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 2fcb36663..7c432e50d 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -28,6 +28,7 @@ pub mod presets; pub mod profiles; pub mod right; pub mod rules_editor; +pub mod rules_move; pub mod second; pub mod settings; pub mod slide_layout; diff --git a/crates/ui-egui/src/panels/rules_move.rs b/crates/ui-egui/src/panels/rules_move.rs new file mode 100644 index 000000000..00dc7af59 --- /dev/null +++ b/crates/ui-egui/src/panels/rules_move.rs @@ -0,0 +1,399 @@ +//! The move engine behind reordering rules and groups in the smart-album editor +//! ([`super::rules_editor`]): pure functions on a [`RuleSet`], and nothing else. A row is a path of +//! positions from the top list (`[1, 0]`: the first rule of the second rule's group); a place to +//! drop is a path too: the group's path and the position among its rules, counted before the row +//! leaves its old place. +//! +//! [`judge`], [`move_node`] and [`key_dest`] never trust a path: one that leads nowhere changes +//! nothing. A group left empty by a move goes away, and groups nest at most [`MAX_GROUP_DEPTH`] deep. + +use lightcraft_catalog::{Rule, RuleSet}; + +/// Groups nest at most this deep: the editor offers a group only in a list whose depth is below it +/// (the top list is depth 0). +pub const MAX_GROUP_DEPTH: usize = 3; + +/// Where the arrow keys send a row (↑ ↓ ← →). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Dir { + Up, + Down, + /// ← : out of its group. + Out, + /// → : into the group next to it. + In, +} + +/// Why a move is refused (shown, not just ignored). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Refusal { + /// A group dropped inside itself or something in it. + IntoItself, + /// A group dropped where its rules would nest deeper than [`MAX_GROUP_DEPTH`]. + TooDeep, +} + +/// What a move of one row to a place would do. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Verdict { + Moves, + /// Where it already is, or a place or row that isn't there. + Nothing, + Refused(Refusal), +} + +/// The group (or the top list) at `path`. +fn list_at<'a>(rs: &'a RuleSet, path: &[usize]) -> Option<&'a RuleSet> { + let mut list = rs; + for &i in path { + match list.rules.get(i)? { + Rule::Group { group } => list = group, + Rule::Field { .. } => return None, + } + } + Some(list) +} + +fn list_at_mut<'a>(rs: &'a mut RuleSet, path: &[usize]) -> Option<&'a mut RuleSet> { + let mut list = rs; + for &i in path { + match list.rules.get_mut(i)? { + Rule::Group { group } => list = group, + Rule::Field { .. } => return None, + } + } + Some(list) +} + +/// The rule or group at `path`. +fn node_at<'a>(rs: &'a RuleSet, path: &[usize]) -> Option<&'a Rule> { + let (last, parent) = path.split_last()?; + list_at(rs, parent)?.rules.get(*last) +} + +/// How many lists a rule brings: none for a field rule, one more than its deepest group for a group. +fn height(rule: &Rule) -> usize { + match rule { + Rule::Field { .. } => 0, + Rule::Group { group } => 1 + group.rules.iter().map(height).max().unwrap_or(0), + } +} + +fn remove_at(rs: &mut RuleSet, path: &[usize]) -> Option { + let (last, parent) = path.split_last()?; + let list = list_at_mut(rs, parent)?; + (*last < list.rules.len()).then(|| list.rules.remove(*last)) +} + +fn insert_at(rs: &mut RuleSet, path: &[usize], rule: Rule) -> Option<()> { + let (last, parent) = path.split_last()?; + let list = list_at_mut(rs, parent)?; + if *last > list.rules.len() { + return None; + } + list.rules.insert(*last, rule); + Some(()) +} + +/// `path` as it reads once the row at `removed` is gone: a later sibling's position drops by one. +/// `false` when `path` runs through the removed row itself. +fn shift_after_removal(path: &mut [usize], removed: &[usize]) -> bool { + let Some((last, parent)) = removed.split_last() else { return true }; + if path.len() <= parent.len() || !path.starts_with(parent) { + return true; + } + let through = path.len() > removed.len(); + let Some(at) = path.get_mut(parent.len()) else { return true }; + if *at > *last { + *at -= 1; + true + } else { + *at != *last || !through + } +} + +/// What moving the row at `from` to the place `dest` would do. +pub fn judge(rs: &RuleSet, from: &[usize], dest: &[usize]) -> Verdict { + let (Some((from_last, from_parent)), Some((dest_last, dest_parent))) = (from.split_last(), dest.split_last()) else { + return Verdict::Nothing; + }; + let Some(node) = node_at(rs, from) else { return Verdict::Nothing }; + if matches!(node, Rule::Group { .. }) && dest_parent.starts_with(from) { + return Verdict::Refused(Refusal::IntoItself); + } + let Some(list) = list_at(rs, dest_parent) else { return Verdict::Nothing }; + if *dest_last > list.rules.len() { + return Verdict::Nothing; + } + // before itself, or after itself: the same place + if dest_parent == from_parent && (dest_last == from_last || *dest_last == from_last.saturating_add(1)) { + return Verdict::Nothing; + } + // a group may not end up deeper than the editor offers, but one that is already deeper (rules + // written by an agent) can still be moved about at its own depth or higher + let h = height(node); + let (now, then) = (from_parent.len() + h, dest_parent.len() + h); + if h > 0 && then > MAX_GROUP_DEPTH && then > now { + return Verdict::Refused(Refusal::TooDeep); + } + Verdict::Moves +} + +/// Moves the row at `from` to the place `dest` (see the module doc), if [`judge`] allows it, and +/// returns where the row is now. Groups it leaves empty go away. All or nothing: `rs` is only +/// replaced by a finished result. +pub fn move_node(rs: &mut RuleSet, from: &[usize], dest: &[usize]) -> Option> { + if judge(rs, from, dest) != Verdict::Moves { + return None; + } + let mut next = rs.clone(); + let node = remove_at(&mut next, from)?; + let mut dest = dest.to_vec(); + if !shift_after_removal(&mut dest, from) { + return None; + } + let mut chain = from.get(..from.len().checked_sub(1)?)?.to_vec(); + // (a group the row is going into stays, however empty it is for now) + while !chain.is_empty() && !(dest.len() > chain.len() && dest.starts_with(&chain)) && list_at(&next, &chain).is_some_and(|l| l.rules.is_empty()) { + remove_at(&mut next, &chain)?; + if !shift_after_removal(&mut dest, &chain) { + return None; + } + chain.pop(); + } + insert_at(&mut next, &dest, node)?; + *rs = next; + Some(dest) +} + +/// The place an arrow key sends the row at `from` to, if there is one (the move may still be +/// refused: [`judge`]). +pub fn key_dest(rs: &RuleSet, from: &[usize], dir: Dir) -> Option> { + let (&i, parent) = from.split_last()?; + let list = list_at(rs, parent)?; + let at = |n: usize| parent.iter().copied().chain([n]).collect::>(); + match dir { + Dir::Up if i > 0 => Some(at(i - 1)), + // the first of a group: out, to just before the group + Dir::Up => (!parent.is_empty()).then(|| parent.to_vec()), + Dir::Down if i + 1 < list.rules.len() => Some(at(i + 2)), + // the last of a group, and ←: out, to just after the group + Dir::Down | Dir::Out => { + let (&g, outer) = parent.split_last()?; + Some(outer.iter().copied().chain([g + 1]).collect()) + } + Dir::In => { + let above = i.checked_sub(1).and_then(|a| match list.rules.get(a)? { + Rule::Group { group } => Some(at(a).into_iter().chain([group.rules.len()]).collect::>()), + Rule::Field { .. } => None, + }); + above.or_else(|| match list.rules.get(i + 1)? { + Rule::Group { .. } => Some(at(i + 1).into_iter().chain([0]).collect()), + Rule::Field { .. } => None, + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{Value, json}; + + fn rs(v: Value) -> RuleSet { + serde_json::from_value(v).unwrap() + } + + /// Each rule is named by its rating, each group by `g` and what is in it: `1 2 (3 4) 5`. + fn shape(rs: &RuleSet) -> String { + let parts: Vec = rs + .rules + .iter() + .map(|r| match r { + Rule::Field { value, .. } => value.to_string(), + Rule::Group { group } => format!("({})", shape(group)), + }) + .collect(); + parts.join(" ") + } + + fn rule(n: u32) -> Value { + json!({"field": "rating", "op": "is", "value": n}) + } + + fn group(rules: Vec) -> Value { + json!({"group": {"match": "any", "rules": rules}}) + } + + fn set(rules: Vec) -> RuleSet { + rs(json!({"rules": rules})) + } + + fn moved(rules: Vec, from: &[usize], dest: &[usize]) -> (String, Option>) { + let mut r = set(rules); + let at = move_node(&mut r, from, dest); + (shape(&r), at) + } + + #[test] + fn a_rule_moves_down_and_up_in_its_list() { + let list = || vec![rule(1), rule(2), rule(3), rule(4)]; + assert_eq!(moved(list(), &[0], &[3]), ("2 3 1 4".into(), Some(vec![2])), "before the fourth"); + assert_eq!(moved(list(), &[0], &[4]), ("2 3 4 1".into(), Some(vec![3])), "to the end"); + assert_eq!(moved(list(), &[3], &[0]), ("4 1 2 3".into(), Some(vec![0])), "to the top"); + assert_eq!(moved(list(), &[2], &[1]), ("1 3 2 4".into(), Some(vec![1]))); + } + + #[test] + fn a_move_to_where_the_row_already_is_changes_nothing() { + let list = || vec![rule(1), rule(2), rule(3)]; + for dest in [[1], [2]] { + assert_eq!(judge(&set(list()), &[1], &dest), Verdict::Nothing); + assert_eq!(moved(list(), &[1], &dest), ("1 2 3".into(), None)); + } + } + + #[test] + fn a_rule_moves_into_and_out_of_a_group() { + let list = || vec![rule(1), group(vec![rule(2), rule(3)]), rule(4)]; + assert_eq!(moved(list(), &[0], &[1, 1]), ("(2 1 3) 4".into(), Some(vec![0, 1])), "into the group, between its rules"); + assert_eq!(moved(list(), &[2], &[1, 2]), ("1 (2 3 4)".into(), Some(vec![1, 2])), "into its end"); + assert_eq!(moved(list(), &[1, 0], &[0]), ("2 1 (3) 4".into(), Some(vec![0])), "out, to the top"); + assert_eq!(moved(list(), &[1, 1], &[3]), ("1 (2) 4 3".into(), Some(vec![3])), "out, to the end"); + } + + #[test] + fn a_group_left_empty_goes_away() { + let list = || vec![rule(1), group(vec![rule(2)]), rule(3)]; + assert_eq!(moved(list(), &[1, 0], &[0]), ("2 1 3".into(), Some(vec![0])), "the group's only rule goes before it"); + assert_eq!(moved(list(), &[1, 0], &[3]), ("1 3 2".into(), Some(vec![2])), "after everything"); + assert_eq!(moved(list(), &[1, 0], &[1]), ("1 2 3".into(), Some(vec![1])), "to the group's own place"); + // the emptied group is inside another: both go + let deep = vec![rule(1), group(vec![group(vec![rule(2)])]), rule(3)]; + assert_eq!(moved(deep, &[1, 0, 0], &[0]), ("2 1 3".into(), Some(vec![0]))); + // into a group, and the old group is gone: the places after it shift + let two = vec![group(vec![rule(1)]), group(vec![rule(2)])]; + assert_eq!(moved(two, &[0, 0], &[1, 1]), ("(2 1)".into(), Some(vec![0, 1]))); + } + + #[test] + fn a_group_moves_whole_and_never_into_itself() { + let list = || vec![group(vec![rule(1), rule(2)]), rule(3), group(vec![rule(4)])]; + assert_eq!(moved(list(), &[0], &[2]), ("3 (1 2) (4)".into(), Some(vec![1]))); + assert_eq!(moved(list(), &[0], &[3]), ("3 (4) (1 2)".into(), Some(vec![2])), "to the end"); + assert_eq!(moved(list(), &[0], &[2, 1]), ("3 (4 (1 2))".into(), Some(vec![1, 1])), "into the other group"); + for dest in [vec![0, 0], vec![0, 1], vec![0, 2]] { + assert_eq!(judge(&set(list()), &[0], &dest), Verdict::Refused(Refusal::IntoItself), "{dest:?}"); + assert_eq!(moved(list(), &[0], &dest), ("(1 2) 3 (4)".into(), None)); + } + let nested = || vec![group(vec![rule(1), group(vec![rule(2)])])]; + assert_eq!(judge(&set(nested()), &[0], &[0, 1, 0]), Verdict::Refused(Refusal::IntoItself), "inside what is inside it"); + } + + #[test] + fn groups_stay_within_the_depth_the_editor_offers() { + // a group holding a group holding a group: three lists below the top one + let tall = group(vec![group(vec![group(vec![rule(1)])])]); + let list = || vec![tall.clone(), group(vec![rule(2)])]; + // into the second group it would be four deep + assert_eq!(judge(&set(list()), &[0], &[1, 0]), Verdict::Refused(Refusal::TooDeep)); + assert_eq!(moved(list(), &[0], &[1, 0]).1, None); + // but beside it is fine, and so is a rule or a small group at any depth + assert_eq!(judge(&set(list()), &[0], &[2]), Verdict::Moves); + let flat = vec![rule(1), group(vec![group(vec![group(vec![rule(2)])])])]; + assert_eq!(judge(&set(flat.clone()), &[0], &[1, 0, 0, 0]), Verdict::Moves, "a rule can go into the deepest list"); + let small = vec![group(vec![rule(1)]), group(vec![group(vec![group(vec![rule(2)])])])]; + assert_eq!(judge(&set(small.clone()), &[0], &[1, 0, 0, 0]), Verdict::Refused(Refusal::TooDeep), "a group can't"); + assert_eq!(judge(&set(small), &[0], &[1, 0, 0]), Verdict::Moves, "one level up it can"); + } + + #[test] + fn a_group_that_is_already_too_deep_can_still_be_reordered() { + let too_deep = group(vec![group(vec![group(vec![group(vec![rule(1)]), group(vec![rule(2)])])])]); + let r = set(vec![too_deep]); + // the two innermost groups swap places: same depth, allowed + assert_eq!(judge(&r, &[0, 0, 0, 0], &[0, 0, 0, 2]), Verdict::Moves); + // moving one of them deeper is not + assert_eq!(judge(&r, &[0, 0, 0, 0], &[0, 0, 0, 1, 0]), Verdict::Refused(Refusal::TooDeep)); + } + + #[test] + fn paths_that_lead_nowhere_change_nothing() { + let r = || set(vec![rule(1), group(vec![rule(2)])]); + for (from, dest) in [ + (vec![], vec![0]), + (vec![0], vec![]), + (vec![5], vec![0]), + (vec![0], vec![9]), + (vec![0], vec![0, 0]), + (vec![0], vec![5, 0]), + (vec![1, 4], vec![0]), + (vec![usize::MAX], vec![0]), + (vec![0], vec![usize::MAX]), + (vec![1], vec![1, usize::MAX]), + ] { + let mut x = r(); + assert_eq!(move_node(&mut x, &from, &dest), None, "{from:?} -> {dest:?}"); + assert_eq!(x, r()); + } + } + + #[test] + fn arrow_keys_move_a_row_one_place() { + let list = || vec![rule(1), group(vec![rule(2), rule(3)]), rule(4)]; + let key = |from: &[usize], dir| { + let mut r = set(list()); + let at = key_dest(&r, from, dir).and_then(|d| move_node(&mut r, from, &d)); + (shape(&r), at) + }; + assert_eq!(key(&[0], Dir::Down), ("(2 3) 1 4".into(), Some(vec![1])), "a group is passed, not entered"); + assert_eq!(key(&[2], Dir::Up), ("1 4 (2 3)".into(), Some(vec![1]))); + assert_eq!(key(&[0], Dir::Up), ("1 (2 3) 4".into(), None), "the top stays"); + assert_eq!(key(&[2], Dir::Down), ("1 (2 3) 4".into(), None), "the end stays"); + // at a group's ends, ↑ ↓ carry the row out of it + assert_eq!(key(&[1, 0], Dir::Up), ("1 2 (3) 4".into(), Some(vec![1]))); + assert_eq!(key(&[1, 1], Dir::Down), ("1 (2) 3 4".into(), Some(vec![2]))); + assert_eq!(key(&[1, 0], Dir::Down), ("1 (3 2) 4".into(), Some(vec![1, 1])), "inside, one place down"); + // ← : out, just after the group; at the top level nothing + assert_eq!(key(&[1, 0], Dir::Out), ("1 (3) 2 4".into(), Some(vec![2]))); + assert_eq!(key(&[0], Dir::Out), ("1 (2 3) 4".into(), None)); + // → : into the group above (its end) or below (its start) + assert_eq!(key(&[2], Dir::In), ("1 (2 3 4)".into(), Some(vec![1, 2]))); + assert_eq!(key(&[0], Dir::In), ("(1 2 3) 4".into(), Some(vec![0, 0]))); + let lone = || vec![rule(1), rule(2)]; + let mut r = set(lone()); + assert_eq!(key_dest(&r, &[0], Dir::In), None, "no group beside it"); + assert_eq!(move_node(&mut r, &[0], &[0]), None); + } + + #[test] + fn a_rows_only_rule_going_out_takes_its_group_with_it() { + let list = || vec![group(vec![rule(1)]), rule(2)]; + let mut r = set(list()); + let at = key_dest(&r, &[0, 0], Dir::Down).and_then(|d| move_node(&mut r, &[0, 0], &d)); + assert_eq!((shape(&r), at), ("1 2".into(), Some(vec![0])), "the group would be empty"); + let mut r = set(list()); + let at = key_dest(&r, &[0, 0], Dir::Out).and_then(|d| move_node(&mut r, &[0, 0], &d)); + assert_eq!((shape(&r), at), ("1 2".into(), Some(vec![0])), "← too: just after a group that is gone"); + } + + #[test] + fn a_row_leaving_nested_groups_may_leave_the_group_it_is_going_to_empty() { + let mut r = set(vec![group(vec![group(vec![rule(1)])])]); + assert_eq!(move_node(&mut r, &[0, 0, 0], &[0, 0]), Some(vec![0, 0])); + assert_eq!(shape(&r), "(1)"); + let mut r = set(vec![group(vec![group(vec![rule(1)])])]); + let dest = key_dest(&r, &[0, 0, 0], Dir::Out).unwrap(); + assert!(move_node(&mut r, &[0, 0, 0], &dest).is_some()); + assert_eq!(shape(&r), "(1)"); + } + + #[test] + fn arrow_keys_respect_the_depth_limit() { + let tall = group(vec![group(vec![group(vec![rule(1)])])]); + let r = set(vec![tall.clone(), group(vec![rule(2)])]); + // → would put it in the group below: four deep + let dest = key_dest(&r, &[0], Dir::In).unwrap(); + assert_eq!(judge(&r, &[0], &dest), Verdict::Refused(Refusal::TooDeep)); + } +}