diff --git a/gpx-rs/engine/src/engine/action.rs b/gpx-rs/engine/src/engine/action.rs index 833e7d3ef..6f6aeb310 100644 --- a/gpx-rs/engine/src/engine/action.rs +++ b/gpx-rs/engine/src/engine/action.rs @@ -3,13 +3,22 @@ use crate::{Command, FileId}; /// Everything the frontend can ask the engine to do. /// /// Only [`Action::Edit`] changes the files (and records an undo step); the others act on the -/// history or on the selection. +/// history, on the selection or on the file order. #[derive(Debug)] pub enum Action<'a> { Edit(Command<'a>), Undo, Redo, - Select { file_ids: Vec }, - AddSelect { file_ids: Vec }, + Select { + file_ids: Vec, + }, + AddSelect { + file_ids: Vec, + }, SelectAll, + /// Moves the files to `index` among the other files, in the given order. Not undoable. + Reorder { + file_ids: Vec, + index: usize, + }, } diff --git a/gpx-rs/engine/src/engine/engine.rs b/gpx-rs/engine/src/engine/engine.rs index 105f2d9cc..8c0988499 100644 --- a/gpx-rs/engine/src/engine/engine.rs +++ b/gpx-rs/engine/src/engine/engine.rs @@ -45,6 +45,7 @@ impl Engine { } true } + Action::Reorder { file_ids, index } => self.order.move_files(&file_ids, index), Action::SelectAll => { self.selection = Selection::File { file_ids: self.order.0.iter().copied().collect(), @@ -74,6 +75,17 @@ impl Engine { /// Brings everything derived from the files back in line with the current stack entry. fn refresh(&mut self) { let current = self.stack.current(); + match current { + Some(files) => { + // files coming back after an undo/redo are added at the end of the order + self.order.sync(files); + self.selection.retain_existing(files); + } + None => { + self.order.0.clear(); + self.selection = Selection::Empty; + } + } self.statistics_cache.update(current); self.statistics_buffer .update(&self.statistics_cache.get(current, &self.selection)); @@ -240,6 +252,34 @@ mod tests { assert!(engine.stack.current().is_none()); } + #[test] + fn test_reorder_is_not_undoable_and_returning_files_go_last() { + let mut engine = Engine::default(); + new(&mut engine, "a"); + let a = engine.order.0[0]; + new(&mut engine, "b"); + let b = engine.order.0[1]; + new(&mut engine, "c"); + let c = engine.order.0[2]; + + assert!(engine.execute(Action::Reorder { + file_ids: vec![c], + index: 0 + })); + assert_eq!(engine.order.0, vec![c, a, b]); + assert!(!engine.execute(Action::Reorder { + file_ids: vec![c], + index: 0 + })); + + // undoing the creation of c does not undo the reorder + assert!(engine.execute(Action::Undo)); + assert_eq!(engine.order.0, vec![a, b]); + // c comes back at the end + assert!(engine.execute(Action::Redo)); + assert_eq!(engine.order.0, vec![a, b, c]); + } + #[test] fn test_undo_updates_statistics() { let mut engine = Engine::default(); diff --git a/gpx-rs/engine/src/engine/state/order.rs b/gpx-rs/engine/src/engine/state/order.rs index 5c63c027e..fce59cf67 100644 --- a/gpx-rs/engine/src/engine/state/order.rs +++ b/gpx-rs/engine/src/engine/state/order.rs @@ -1,4 +1,97 @@ -use crate::FileId; +use std::collections::HashSet; -#[derive(Debug, Default)] +use crate::{FileId, StackEntry as Files}; + +#[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct FileOrder(pub Vec); + +impl FileOrder { + /// Moves the given files, as a block and in the given order, so that they start at `index` + /// among the other files (clamped to the end). Unknown ids are ignored. Returns whether the + /// order changed. + pub fn move_files(&mut self, file_ids: &[FileId], index: usize) -> bool { + let mut seen = HashSet::new(); + let moved: Vec = file_ids + .iter() + .copied() + .filter(|id| self.0.contains(id) && seen.insert(*id)) + .collect(); + let mut order: Vec = self + .0 + .iter() + .copied() + .filter(|id| !seen.contains(id)) + .collect(); + let index = index.min(order.len()); + order.splice(index..index, moved); + let changed = order != self.0; + self.0 = order; + changed + } + + /// Drops the files that do not exist anymore and appends the new ones at the end. + pub fn sync(&mut self, files: &Files) { + self.0.retain(|id| files.contains_key(id)); + let known: HashSet = self.0.iter().copied().collect(); + self.0 + .extend(files.keys().filter(|id| !known.contains(id)).copied()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids() -> [FileId; 3] { + [FileId::default(), FileId::default(), FileId::default()] + } + + #[test] + fn test_move_files_as_block_in_given_order() { + let [a, b, c] = ids(); + let mut order = FileOrder(vec![a, b, c]); + assert!(order.move_files(&[c, a], 1)); + // index counts among the files that are not moved + assert_eq!(order.0, vec![b, c, a]); + } + + #[test] + fn test_move_files_start_and_clamped_end() { + let [a, b, c] = ids(); + let mut order = FileOrder(vec![a, b, c]); + assert!(order.move_files(&[c], 0)); + assert_eq!(order.0, vec![c, a, b]); + assert!(order.move_files(&[c], 100)); + assert_eq!(order.0, vec![a, b, c]); + } + + #[test] + fn test_move_files_ignores_unknown_and_duplicate_ids() { + let [a, b, c] = ids(); + let mut order = FileOrder(vec![a, b, c]); + assert!(order.move_files(&[FileId::default(), b, b], 0)); + assert_eq!(order.0, vec![b, a, c]); + } + + #[test] + fn test_move_files_unchanged() { + let [a, b, c] = ids(); + let mut order = FileOrder(vec![a, b, c]); + assert!(!order.move_files(&[], 0)); + assert!(!order.move_files(&[a, b], 0)); + assert!(!order.move_files(&[FileId::default()], 0)); + assert_eq!(order.0, vec![a, b, c]); + } + + #[test] + fn test_sync_drops_missing_and_appends_new_at_the_end() { + let [a, b, c] = ids(); + let mut files = Files::default(); + for id in [b, c] { + files.insert(id, Default::default()); + } + let mut order = FileOrder(vec![a, b]); + order.sync(&files); + assert_eq!(order.0, vec![b, c]); + } +} diff --git a/gpx-rs/wasm/src/lib.rs b/gpx-rs/wasm/src/lib.rs index 2c1911e10..637208f02 100644 --- a/gpx-rs/wasm/src/lib.rs +++ b/gpx-rs/wasm/src/lib.rs @@ -141,6 +141,13 @@ pub fn delete_all() -> bool { edit(Command::DeleteAll(engine::DeleteAll)) } +/// Moves the files to `index` among the other files, in the given order (not undoable). +/// `file_ids_bytes`: concatenated 16-byte UUIDs. +#[wasm_bindgen] +pub fn reorder(file_ids_bytes: &[u8], index: usize) -> bool { + file_ids(file_ids_bytes).is_some_and(|file_ids| execute(Action::Reorder { file_ids, index })) +} + // Edit commands #[wasm_bindgen]