From fbe1225202ae268c288791aa86de8d640677f9fc Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 31 Oct 2026 02:07:01 +0000 Subject: [PATCH] Merge PR #2707: LR-BEHAV-MULTISELECT: the model for choosing several sidebar rows with Cmd/Ctrl+click and Shift+click --- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/multi_select.rs | 232 +++++++++++++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 crates/ui-egui/src/multi_select.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f3a800178..3b947645a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -32,6 +32,7 @@ pub mod lightroom_import; pub mod links; pub mod menus; mod model_setup; +pub mod multi_select; pub mod panels; pub mod pick; pub mod recheck; diff --git a/crates/ui-egui/src/multi_select.rs b/crates/ui-egui/src/multi_select.rs new file mode 100644 index 000000000..80393024d --- /dev/null +++ b/crates/ui-egui/src/multi_select.rs @@ -0,0 +1,232 @@ +//! Choosing several rows of a list at once: Cmd/Ctrl+click adds a row or takes it away, +//! Shift+click takes every row from the row last clicked without Shift (else the row chosen last) +//! to this one. One rule for the sidebars' lists (albums, folders, keywords, dates, the Keyword +//! List), so they all answer the same way. +//! +//! The rows chosen are kept in the order chosen; the last of them is the one an action for a +//! single row takes (Rename…, Edit…). +//! +//! Nothing calls this yet. It is the model for LR-BEHAV-MULTISELECT's sidebar multi-select (the +//! albums, folders, keywords and dates lists and the Keyword List), whose menus then act on every +//! row chosen through the batch commands (`album.delete`, `keyword.delete`, … taking several +//! ids). It keeps no egui state (only `egui::Modifiers` says which keys were held) and calls no +//! engine code, so those parts can be built on it. + +/// What a click asks for, by the keys held with it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Click { + /// Choose this row alone. + Plain, + /// Cmd/Ctrl: add this row to those chosen, or take it away. + Toggle, + /// Shift: the rows from the anchor to this one, instead of what was chosen. + Range, + /// Cmd/Ctrl+Shift: the rows from the anchor to this one, added to what was chosen. + AddRange, +} + +impl Click { + pub fn of(m: egui::Modifiers) -> Click { + match (m.shift, m.command) { + (true, true) => Click::AddRange, + (true, false) => Click::Range, + (false, true) => Click::Toggle, + (false, false) => Click::Plain, + } + } +} + +/// The rows chosen after a click, and where the next Shift+click starts from. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Chosen { + /// In the order chosen; the last is the one a single-row action takes. + pub keys: Vec, + pub anchor: Option, +} + +/// What a click on the row `at` makes of the rows `chosen`. `rows` are the list's rows as drawn, +/// top to bottom: every row of an open level (also one a frame skipped drawing because it was +/// scrolled out of view), and none inside a closed level, so a range never takes rows that are +/// folded away; `anchor` is the row last clicked without Shift; `same` tells two rows' keys alike. +/// +/// A range needs its anchor among `rows`: without one (nothing clicked yet, or it was folded +/// away) Shift+click chooses the row alone, like a plain click. +pub fn click(rows: &[K], chosen: &[K], anchor: Option<&K>, at: &K, how: Click, same: &dyn Fn(&K, &K) -> bool) -> Chosen { + let alone = || Chosen { keys: vec![at.clone()], anchor: Some(at.clone()) }; + match how { + Click::Plain => alone(), + Click::Toggle => { + let mut keys: Vec = chosen.to_vec(); + let before = keys.len(); + keys.retain(|k| !same(k, at)); + if keys.len() == before { + keys.push(at.clone()); + } + Chosen { keys, anchor: Some(at.clone()) } + } + Click::Range | Click::AddRange => { + let index = |k: &K| rows.iter().position(|r| same(r, k)); + let (Some(from), Some(to)) = (anchor.and_then(index), index(at)) else { return alone() }; + let mut keys: Vec = if how == Click::AddRange { chosen.to_vec() } else { Vec::new() }; + // from the anchor towards the row clicked, so that one is the last chosen + let range: Vec<&K> = if from <= to { + rows.get(from..=to).unwrap_or_default().iter().collect() + } else { + rows.get(to..=from).unwrap_or_default().iter().rev().collect() + }; + for k in range { + keys.retain(|x| !same(x, k)); + keys.push(k.clone()); + } + Chosen { keys, anchor: anchor.cloned() } + } + } +} + +/// What a context menu opened on the row `at` acts on: every row chosen when `at` is one of +/// several chosen, else that row alone (the rows chosen stay as they are either way). +pub fn targets(chosen: &[K], at: &K, same: &dyn Fn(&K, &K) -> bool) -> Vec { + if chosen.len() > 1 && chosen.iter().any(|k| same(k, at)) { chosen.to_vec() } else { vec![at.clone()] } +} + +/// One list's rows as drawn, for Shift+click: `seen` while a frame draws them, `rows` of the +/// frame before (what the person clicked on), and the anchor. +#[derive(Clone, Debug)] +pub struct ListRows { + seen: Vec, + rows: Vec, + pub anchor: Option, +} + +impl Default for ListRows { + fn default() -> Self { + ListRows { seen: Vec::new(), rows: Vec::new(), anchor: None } + } +} + +impl ListRows { + /// A new frame: the rows of the last one are what a click now is on. + pub fn begin_frame(&mut self) { + std::mem::swap(&mut self.rows, &mut self.seen); + self.seen.clear(); + } + /// A row of this frame, in drawing order. + pub fn saw(&mut self, key: K) { + self.seen.push(key); + } + /// The rows a click is on: the last frame's. + pub fn rows(&self) -> &[K] { + &self.rows + } + /// A click on `at` with `how`, among the rows drawn; remembers the anchor. + pub fn click(&mut self, chosen: &[K], at: &K, how: Click, same: &dyn Fn(&K, &K) -> bool) -> Vec { + // a range starts from a row that is chosen: the one last clicked while it still is, else + // the one chosen last (chosen by a menu, an agent or the last session, or after what was + // clicked stopped being shown) + let anchor = self.anchor.as_ref().filter(|a| chosen.iter().any(|c| same(c, a))).or(chosen.last()); + let out = click(&self.rows, chosen, anchor, at, how, same); + self.anchor = out.anchor; + out.keys + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ROWS: [&str; 6] = ["a", "b", "c", "d", "e", "f"]; + fn eq(a: &&str, b: &&str) -> bool { + a == b + } + fn go(chosen: &[&'static str], anchor: Option<&'static str>, at: &'static str, how: Click) -> (Vec<&'static str>, Option<&'static str>) { + let out = click(&ROWS, chosen, anchor.as_ref(), &at, how, &eq); + (out.keys, out.anchor) + } + + #[test] + fn the_keys_held_say_what_a_click_asks_for() { + use egui::Modifiers as M; + assert_eq!(Click::of(M::NONE), Click::Plain); + assert_eq!(Click::of(M::COMMAND), Click::Toggle); + assert_eq!(Click::of(M::SHIFT), Click::Range); + assert_eq!(Click::of(M::SHIFT | M::COMMAND), Click::AddRange); + // Alt alone asks for nothing special + assert_eq!(Click::of(M::ALT), Click::Plain); + } + + #[test] + fn a_plain_click_chooses_the_row_alone() { + assert_eq!(go(&["a", "c"], Some("a"), "e", Click::Plain), (vec!["e"], Some("e"))); + assert_eq!(go(&["a", "c"], Some("a"), "c", Click::Plain), (vec!["c"], Some("c")), "also one of several chosen"); + } + + #[test] + fn cmd_click_adds_a_row_or_takes_it_away() { + assert_eq!(go(&["a"], Some("a"), "c", Click::Toggle), (vec!["a", "c"], Some("c"))); + assert_eq!(go(&["a", "c"], Some("c"), "a", Click::Toggle), (vec!["c"], Some("a"))); + assert_eq!(go(&["a"], Some("a"), "a", Click::Toggle), (vec![], Some("a")), "the last one can be taken away too"); + } + + #[test] + fn shift_click_takes_the_rows_from_the_anchor() { + // downwards and upwards; the row clicked is the last chosen, and the anchor stays + assert_eq!(go(&["b"], Some("b"), "e", Click::Range), (vec!["b", "c", "d", "e"], Some("b"))); + assert_eq!(go(&["e"], Some("e"), "c", Click::Range), (vec!["e", "d", "c"], Some("e"))); + // a second Shift+click from the same anchor replaces the first range + assert_eq!(go(&["b", "c", "d", "e"], Some("b"), "c", Click::Range), (vec!["b", "c"], Some("b"))); + // …and what was chosen elsewhere goes + assert_eq!(go(&["f", "b"], Some("b"), "c", Click::Range), (vec!["b", "c"], Some("b"))); + } + + #[test] + fn cmd_shift_click_adds_the_range_to_what_was_chosen() { + assert_eq!(go(&["a", "d"], Some("d"), "f", Click::AddRange), (vec!["a", "d", "e", "f"], Some("d"))); + // a row already chosen is not chosen twice, and the row clicked is still the last + assert_eq!(go(&["e", "c"], Some("c"), "e", Click::AddRange), (vec!["c", "d", "e"], Some("c"))); + } + + #[test] + fn a_range_without_an_anchor_in_sight_chooses_the_row_alone() { + assert_eq!(go(&[], None, "c", Click::Range), (vec!["c"], Some("c"))); + // the anchor was folded away since (not among the rows drawn) + assert_eq!(go(&["x"], Some("x"), "c", Click::Range), (vec!["c"], Some("c"))); + // a row that is not drawn (a stale click) chooses itself and nothing else + assert_eq!(go(&["a"], Some("a"), "zz", Click::AddRange), (vec!["zz"], Some("zz"))); + // the anchor is the row clicked: that row alone, anchor kept + assert_eq!(go(&["b", "c"], Some("b"), "b", Click::Range), (vec!["b"], Some("b"))); + // no rows drawn at all + let none: [&str; 0] = []; + assert_eq!(click(&none, &["a"], Some(&"a"), &"c", Click::Range, &eq).keys, vec!["c"]); + } + + #[test] + fn a_menu_acts_on_the_rows_chosen_only_when_opened_on_one_of_them() { + assert_eq!(targets(&["a", "c"], &"c", &eq), vec!["a", "c"]); + assert_eq!(targets(&["a", "c"], &"e", &eq), vec!["e"], "another row: that row alone"); + assert_eq!(targets(&["a"], &"a", &eq), vec!["a"]); + assert_eq!(targets(&[], &"a", &eq), vec!["a"]); + } + + #[test] + fn a_list_s_clicks_are_on_the_rows_of_the_frame_before() { + let mut l: ListRows<&str> = ListRows::default(); + for r in ROWS { + l.saw(r); + } + // nothing drawn before this frame: no range yet + assert_eq!(l.click(&[], &"b", Click::Plain, &eq), vec!["b"]); + l.begin_frame(); + for r in ["a", "b", "e", "f"] { + l.saw(r); // c and d folded away, but this frame is not the one clicked on + } + assert_eq!(l.click(&["b"], &"d", Click::Range, &eq), vec!["b", "c", "d"]); + // the row clicked is not chosen any more (All Photos was shown since): a range does not + // start from it; it starts from what is chosen now, or the row is chosen alone + assert_eq!(l.click(&["f"], &"d", Click::Range, &eq), vec!["f", "e", "d"]); + assert_eq!(l.click(&[], &"c", Click::Range, &eq), vec!["c"]); + assert_eq!(l.click(&["c"], &"b", Click::Plain, &eq), vec!["b"]); + l.begin_frame(); + assert_eq!(l.rows(), ["a", "b", "e", "f"]); + assert_eq!(l.click(&["b"], &"e", Click::Range, &eq), vec!["b", "e"], "rows folded away are not taken"); + } +}