diff --git a/gpx-rs/engine/src/engine/command/command.rs b/gpx-rs/engine/src/engine/command/command.rs index 3b5314b9e..54abd4159 100644 --- a/gpx-rs/engine/src/engine/command/command.rs +++ b/gpx-rs/engine/src/engine/command/command.rs @@ -1,6 +1,6 @@ use crate::{Apply, CommandError, State}; use crate::{ - Clean, Crop, Delete, DeleteAll, Duplicate, Elevation, Extract, Load, Merge, Metadata, + Clean, Crop, Delete, DeleteAll, Duplicate, Elevation, Extract, Load, Merge, Metadata, Move, MoveWaypoint, New, NewTrack, NewTrackSegment, NewWaypoint, Paste, Reverse, SpliceTrackpoints, Split, Style, Time, }; @@ -17,6 +17,7 @@ pub enum Command<'a> { Duplicate(Duplicate), Delete(Delete), Paste(Paste), + Move(Move), DeleteAll(DeleteAll), Metadata(Metadata<'a>), Style(Style<'a>), @@ -43,6 +44,7 @@ impl Apply for Command<'_> { Command::Duplicate(c) => c.apply(state), Command::Delete(c) => c.apply(state), Command::Paste(c) => c.apply(state), + Command::Move(c) => c.apply(state), Command::DeleteAll(c) => c.apply(state), Command::Metadata(c) => c.apply(state), Command::Style(c) => c.apply(state), diff --git a/gpx-rs/engine/src/engine/command/file/mod.rs b/gpx-rs/engine/src/engine/command/file/mod.rs index 30ab68e5a..1ba906f74 100644 --- a/gpx-rs/engine/src/engine/command/file/mod.rs +++ b/gpx-rs/engine/src/engine/command/file/mod.rs @@ -2,12 +2,16 @@ mod delete; mod delete_all; mod duplicate; mod load; +mod move_elements; mod new; mod paste; +mod transfer; pub use delete::*; pub use delete_all::*; pub use duplicate::*; pub use load::*; +pub use move_elements::*; pub use new::*; pub use paste::*; +pub use transfer::*; diff --git a/gpx-rs/engine/src/engine/command/file/move_elements.rs b/gpx-rs/engine/src/engine/command/file/move_elements.rs new file mode 100644 index 000000000..0733fba04 --- /dev/null +++ b/gpx-rs/engine/src/engine/command/file/move_elements.rs @@ -0,0 +1,485 @@ +use crate::{ + Apply, CommandError, FileId, Place, SegmentsTo, Selection, State, TrackId, TracksTo, + transfer_files, transfer_segments, transfer_tracks, transfer_waypoints, +}; + +/// Where moved elements go: a list of the file tree, and the position in it. The position is +/// counted among the elements of the list that are not moved, like the position of a dragged +/// element in the list it is dropped in. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MoveTarget { + /// The list of files. + Files { index: usize }, + /// The tracks of a file. + Tracks { file_id: FileId, index: usize }, + /// The segments of a track. + Segments { + file_id: FileId, + trk_id: TrackId, + index: usize, + }, + /// The waypoints of a file. + Waypoints { file_id: FileId, index: usize }, +} + +/// Moves elements to a place of the file tree (what drag and drop does), with their ids. The +/// moved elements are selected. +/// +/// What can go where: +/// - files: among the files; +/// - tracks: among the files (each track becomes a file), or among the tracks of a file; +/// - segments: among the files (each segment becomes a file), among the tracks of a file (each +/// segment becomes a track), or among the segments of a track; +/// - waypoints, or all the waypoints of a file (the waypoints node): among the waypoints of a +/// file. +#[derive(Debug)] +pub struct Move { + pub what: Selection, + pub to: MoveTarget, +} + +impl Apply for Move { + fn apply(self, state: &mut State) -> Result<(), CommandError> { + use MoveTarget as To; + let next = match (&self.what, self.to) { + (Selection::File { file_ids }, To::Files { index }) => { + transfer_files(state, file_ids, true, Place::Index(index))? + } + (Selection::Track { file_id, trk_ids }, To::Files { index }) => transfer_tracks( + state, + *file_id, + trk_ids, + true, + TracksTo::Files(Place::Index(index)), + )?, + (Selection::Track { file_id, trk_ids }, To::Tracks { file_id: to, index }) => { + transfer_tracks( + state, + *file_id, + trk_ids, + true, + TracksTo::File(to, Place::Index(index)), + )? + } + ( + Selection::TrackSegment { + file_id, + trk_id, + trkseg_ids, + }, + to, + ) => { + let to = match to { + To::Files { index } => SegmentsTo::Files(Place::Index(index)), + To::Tracks { file_id, index } => { + SegmentsTo::Tracks(file_id, Place::Index(index)) + } + To::Segments { + file_id, + trk_id, + index, + } => SegmentsTo::Track(file_id, trk_id, Place::Index(index)), + To::Waypoints { .. } => return Err(CommandError::NothingToDo), + }; + transfer_segments(state, *file_id, *trk_id, trkseg_ids, true, to)? + } + (Selection::Waypoints { file_id }, To::Waypoints { file_id: to, index }) => { + transfer_waypoints(state, *file_id, None, true, to, Place::Index(index))? + } + (Selection::Waypoint { file_id, wpt_ids }, To::Waypoints { file_id: to, index }) => { + transfer_waypoints( + state, + *file_id, + Some(wpt_ids), + true, + to, + Place::Index(index), + )? + } + _ => return Err(CommandError::NothingToDo), + }; + *state.selection = next; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::rc::Rc; + + use crate::{ + File, Track, TrackInfo, TrackSegment, TrackSegmentId, Waypoint, WaypointChunk, WaypointId, + engine::command::fixture::Fixture, new_file, waypoint_ids, + }; + + use super::*; + + /// A file with the given tracks (name, number of segments) and number of waypoints. + fn file(name: &str, tracks: &[(&str, usize)], waypoints: usize) -> File { + let mut file = new_file( + name, + tracks + .iter() + .map(|(name, segments)| Track { + info: TrackInfo { + name: Some((*name).to_owned()), + ..Default::default() + }, + trkseg: (0..*segments).map(|_| TrackSegment::default()).collect(), + ..Default::default() + }) + .collect(), + ); + if waypoints > 0 { + file.wpt = vec![Rc::new(WaypointChunk { + wpt: (0..waypoints).map(|_| Waypoint::default()).collect(), + ..Default::default() + })]; + } + file + } + + fn add(fx: &mut Fixture, file: File) -> FileId { + let id = file.id; + fx.files.insert(id, Rc::new(file)); + fx.order.0.push(id); + id + } + + fn track_ids(fx: &Fixture, id: FileId) -> Vec { + fx.files[&id].trk.iter().map(|trk| trk.id).collect() + } + + fn segment_ids(fx: &Fixture, id: FileId, trk: usize) -> Vec { + fx.files[&id].trk[trk].trkseg.iter().map(|s| s.id).collect() + } + + fn wpt_ids(fx: &Fixture, id: FileId) -> Vec { + waypoint_ids(&fx.files[&id]).collect() + } + + fn do_move(fx: &mut Fixture, what: Selection, to: MoveTarget) -> Result<(), CommandError> { + Move { what, to }.apply(&mut fx.state()) + } + + fn tracks(file_id: FileId, ids: &[TrackId]) -> Selection { + Selection::Track { + file_id, + trk_ids: ids.iter().copied().collect(), + } + } + + fn segments(file_id: FileId, trk_id: TrackId, ids: &[TrackSegmentId]) -> Selection { + Selection::TrackSegment { + file_id, + trk_id, + trkseg_ids: ids.iter().copied().collect(), + } + } + + fn waypoints(file_id: FileId, ids: &[WaypointId]) -> Selection { + Selection::Waypoint { + file_id, + wpt_ids: ids.iter().copied().collect(), + } + } + + fn files(ids: &[FileId]) -> Selection { + Selection::File { + file_ids: ids.iter().copied().collect(), + } + } + + #[test] + fn test_reorder_tracks_in_a_file() { + let build = || { + let mut fx = Fixture::default(); + let a = add( + &mut fx, + file("a", &[("0", 1), ("1", 1), ("2", 1), ("3", 1)], 0), + ); + let t = track_ids(&fx, a); + (fx, a, t) + }; + let at = |a, index| MoveTarget::Tracks { file_id: a, index }; + + // the position counts the tracks that stay + let (mut fx, a, t) = build(); + do_move(&mut fx, tracks(a, &[t[0]]), at(a, 1)).unwrap(); + assert_eq!(track_ids(&fx, a), vec![t[1], t[0], t[2], t[3]]); + assert_eq!(fx.selection, tracks(a, &[t[0]])); + + let (mut fx, a, t) = build(); + do_move(&mut fx, tracks(a, &[t[3]]), at(a, 0)).unwrap(); + assert_eq!(track_ids(&fx, a), vec![t[3], t[0], t[1], t[2]]); + + // several at once, as a block, in their order; past the end is the end + let (mut fx, a, t) = build(); + do_move(&mut fx, tracks(a, &[t[1], t[0]]), at(a, 99)).unwrap(); + assert_eq!(track_ids(&fx, a), vec![t[2], t[3], t[0], t[1]]); + assert_eq!(fx.selection, tracks(a, &[t[0], t[1]])); + } + + #[test] + fn test_move_tracks_to_another_file_or_to_new_files() { + let mut fx = Fixture::default(); + let a = add(&mut fx, file("a", &[("t0", 1), ("t1", 1)], 0)); + let b = add(&mut fx, file("b", &[("b0", 1), ("b1", 1)], 0)); + let c = add(&mut fx, file("c", &[], 0)); + let t = track_ids(&fx, a); + let b_tracks = track_ids(&fx, b); + + do_move( + &mut fx, + tracks(a, &[t[0]]), + MoveTarget::Tracks { + file_id: b, + index: 1, + }, + ) + .unwrap(); + assert_eq!(track_ids(&fx, a), vec![t[1]]); + assert_eq!(track_ids(&fx, b), vec![b_tracks[0], t[0], b_tracks[1]]); + assert_eq!(fx.selection, tracks(b, &[t[0]])); + + // as a new file, at a position of the list of files + do_move(&mut fx, tracks(a, &[t[1]]), MoveTarget::Files { index: 1 }).unwrap(); + assert!(track_ids(&fx, a).is_empty()); + assert_eq!(fx.order.0.len(), 4); + let new = fx.order.0[1]; + assert_eq!(fx.order.0[0], a); + assert_eq!(fx.order.0[2..], [b, c]); + assert_eq!(track_ids(&fx, new), vec![t[1]]); + assert_eq!(fx.files[&new].info.name, "t1"); + assert_eq!(fx.selection, files(&[new])); + } + + #[test] + fn test_move_segments() { + let build = || { + let mut fx = Fixture::default(); + let a = add(&mut fx, file("a", &[("t0", 3), ("t1", 1)], 0)); + let t = track_ids(&fx, a); + (fx, a, t) + }; + + // within a track + let (mut fx, a, t) = build(); + let s = segment_ids(&fx, a, 0); + do_move( + &mut fx, + segments(a, t[0], &[s[2]]), + MoveTarget::Segments { + file_id: a, + trk_id: t[0], + index: 0, + }, + ) + .unwrap(); + assert_eq!(segment_ids(&fx, a, 0), vec![s[2], s[0], s[1]]); + assert_eq!(fx.selection, segments(a, t[0], &[s[2]])); + + // to another track + let (mut fx, a, t) = build(); + let s = segment_ids(&fx, a, 0); + let other = segment_ids(&fx, a, 1); + do_move( + &mut fx, + segments(a, t[0], &[s[0], s[1]]), + MoveTarget::Segments { + file_id: a, + trk_id: t[1], + index: 0, + }, + ) + .unwrap(); + assert_eq!(segment_ids(&fx, a, 0), vec![s[2]]); + assert_eq!(segment_ids(&fx, a, 1), vec![s[0], s[1], other[0]]); + + // as new tracks of a file + let (mut fx, a, t) = build(); + let s = segment_ids(&fx, a, 0); + do_move( + &mut fx, + segments(a, t[0], &[s[1]]), + MoveTarget::Tracks { + file_id: a, + index: 1, + }, + ) + .unwrap(); + assert_eq!(fx.files[&a].trk.len(), 3); + assert_eq!(segment_ids(&fx, a, 1), vec![s[1]]); + assert_eq!(segment_ids(&fx, a, 0), vec![s[0], s[2]]); + assert_eq!(fx.files[&a].trk[0].id, t[0]); + assert_eq!(fx.files[&a].trk[2].id, t[1]); + + // as new files + let (mut fx, a, t) = build(); + let s = segment_ids(&fx, a, 0); + do_move( + &mut fx, + segments(a, t[0], &[s[0], s[1]]), + MoveTarget::Files { index: 0 }, + ) + .unwrap(); + assert_eq!(fx.order.0.len(), 3); + assert_eq!(fx.order.0[2], a); + assert_eq!(fx.files[&fx.order.0[0]].info.name, "t0"); + assert_eq!(segment_ids(&fx, a, 0), vec![s[2]]); + } + + #[test] + fn test_move_waypoints() { + let build = || { + let mut fx = Fixture::default(); + let a = add(&mut fx, file("a", &[], 4)); + let b = add(&mut fx, file("b", &[], 2)); + (fx, a, b) + }; + let at = |file_id, index| MoveTarget::Waypoints { file_id, index }; + + // within a file + let (mut fx, a, _) = build(); + let w = wpt_ids(&fx, a); + do_move(&mut fx, waypoints(a, &[w[3]]), at(a, 1)).unwrap(); + assert_eq!(wpt_ids(&fx, a), vec![w[0], w[3], w[1], w[2]]); + assert_eq!(fx.selection, waypoints(a, &[w[3]])); + + // to the start of another file + let (mut fx, a, b) = build(); + let w = wpt_ids(&fx, a); + let before = wpt_ids(&fx, b); + do_move(&mut fx, waypoints(a, &[w[0], w[1]]), at(b, 0)).unwrap(); + assert_eq!(wpt_ids(&fx, a), w[2..].to_vec()); + assert_eq!(wpt_ids(&fx, b), [&w[..2], &before[..]].concat()); + + // all the waypoints of a file + let (mut fx, a, b) = build(); + let w = wpt_ids(&fx, a); + let before = wpt_ids(&fx, b); + do_move(&mut fx, Selection::Waypoints { file_id: a }, at(b, 1)).unwrap(); + assert!(wpt_ids(&fx, a).is_empty()); + assert_eq!( + wpt_ids(&fx, b), + [&before[..1], &w[..], &before[1..]].concat() + ); + } + + #[test] + fn test_move_files() { + let mut fx = Fixture::default(); + let a = add(&mut fx, file("a", &[], 0)); + let b = add(&mut fx, file("b", &[], 0)); + let c = add(&mut fx, file("c", &[], 0)); + do_move(&mut fx, files(&[c]), MoveTarget::Files { index: 0 }).unwrap(); + assert_eq!(fx.order.0, vec![c, a, b]); + do_move(&mut fx, files(&[c, a]), MoveTarget::Files { index: 1 }).unwrap(); + assert_eq!(fx.order.0, vec![b, c, a]); + assert_eq!(fx.selection, files(&[c, a])); + } + + #[test] + fn test_what_cannot_be_moved() { + let mut fx = Fixture::default(); + let a = add(&mut fx, file("a", &[("t", 1)], 1)); + let t = track_ids(&fx, a); + let s = segment_ids(&fx, a, 0); + let w = wpt_ids(&fx, a); + let before = fx.files[&a].clone(); + let selection = fx.selection.clone(); + + let invalid = [ + // the wrong kind of list + ( + files(&[a]), + MoveTarget::Tracks { + file_id: a, + index: 0, + }, + ), + ( + tracks(a, &t), + MoveTarget::Waypoints { + file_id: a, + index: 0, + }, + ), + ( + tracks(a, &t), + MoveTarget::Segments { + file_id: a, + trk_id: t[0], + index: 0, + }, + ), + ( + segments(a, t[0], &s), + MoveTarget::Waypoints { + file_id: a, + index: 0, + }, + ), + ( + waypoints(a, &w), + MoveTarget::Tracks { + file_id: a, + index: 0, + }, + ), + (waypoints(a, &w), MoveTarget::Files { index: 0 }), + ( + Selection::Waypoints { file_id: a }, + MoveTarget::Files { index: 0 }, + ), + (Selection::Empty, MoveTarget::Files { index: 0 }), + // elements that do not exist + ( + tracks(a, &[TrackId::default()]), + MoveTarget::Files { index: 0 }, + ), + ( + waypoints(a, &[WaypointId::default()]), + MoveTarget::Waypoints { + file_id: a, + index: 0, + }, + ), + (files(&[FileId::default()]), MoveTarget::Files { index: 0 }), + // a place that does not exist + ( + tracks(a, &t), + MoveTarget::Tracks { + file_id: FileId::default(), + index: 0, + }, + ), + ( + segments(a, t[0], &s), + MoveTarget::Segments { + file_id: a, + trk_id: TrackId::default(), + index: 0, + }, + ), + ( + waypoints(a, &w), + MoveTarget::Waypoints { + file_id: FileId::default(), + index: 0, + }, + ), + ]; + for (what, to) in invalid { + assert_eq!( + do_move(&mut fx, what.clone(), to), + Err(CommandError::NothingToDo), + "{what:?} to {to:?}" + ); + // nothing changed + assert!(Rc::ptr_eq(&fx.files[&a], &before)); + assert_eq!(fx.order.0, vec![a]); + assert_eq!(fx.selection, selection); + } + } +} diff --git a/gpx-rs/engine/src/engine/command/file/paste.rs b/gpx-rs/engine/src/engine/command/file/paste.rs index 6f484c2fc..33447407e 100644 --- a/gpx-rs/engine/src/engine/command/file/paste.rs +++ b/gpx-rs/engine/src/engine/command/file/paste.rs @@ -1,9 +1,8 @@ -use std::{collections::HashSet, hash::Hash, rc::Rc}; +use std::{collections::HashSet, hash::Hash}; use crate::{ - Apply, CommandError, File, FileId, Selection, State, Track, TrackId, TrackSegment, - TrackSegmentId, Waypoint, WaypointId, copy_file, copy_segment, copy_track, copy_waypoint, - edit_waypoint_chunks, insert_waypoints, + Apply, CommandError, Place, SegmentsTo, Selection, State, TracksTo, transfer_files, + transfer_segments, transfer_tracks, transfer_waypoints, waypoint_ids, }; /// Pastes what was copied or cut (see [`crate::Clipboard`]), and empties the clipboard. @@ -31,16 +30,83 @@ impl Apply for Paste { .ok_or(CommandError::NothingToDo)?; let cut = clipboard.cut; let next = match &clipboard.selection { - Selection::File { file_ids } => paste_files(state, file_ids, cut)?, - Selection::Track { file_id, trk_ids } => paste_tracks(state, *file_id, trk_ids, cut)?, + Selection::File { file_ids } => transfer_files(state, file_ids, cut, Place::End)?, + Selection::Track { file_id, trk_ids } => { + let to = match &*state.selection { + Selection::Empty => TracksTo::Files(Place::End), + Selection::File { file_ids } => TracksTo::File( + last_selected(state.order.0.iter().copied(), file_ids) + .ok_or(CommandError::NothingToDo)?, + Place::End, + ), + Selection::Track { file_id, trk_ids } => { + let file = state.files.get(file_id).ok_or(CommandError::NothingToDo)?; + let last = last_selected(file.trk.iter().map(|trk| trk.id), trk_ids) + .ok_or(CommandError::NothingToDo)?; + TracksTo::File(*file_id, Place::After(last)) + } + _ => return Err(CommandError::NothingToDo), + }; + transfer_tracks(state, *file_id, trk_ids, cut, to)? + } Selection::TrackSegment { file_id, trk_id, trkseg_ids, - } => paste_segments(state, *file_id, *trk_id, trkseg_ids, cut)?, - Selection::Waypoints { file_id } => paste_waypoints(state, *file_id, None, cut)?, - Selection::Waypoint { file_id, wpt_ids } => { - paste_waypoints(state, *file_id, Some(wpt_ids), cut)? + } => { + let to = match &*state.selection { + Selection::Empty => SegmentsTo::Files(Place::End), + Selection::File { file_ids } => SegmentsTo::Tracks( + last_selected(state.order.0.iter().copied(), file_ids) + .ok_or(CommandError::NothingToDo)?, + Place::End, + ), + Selection::Track { file_id, trk_ids } => { + let file = state.files.get(file_id).ok_or(CommandError::NothingToDo)?; + let last = last_selected(file.trk.iter().map(|trk| trk.id), trk_ids) + .ok_or(CommandError::NothingToDo)?; + SegmentsTo::Track(*file_id, last, Place::End) + } + Selection::TrackSegment { + file_id, + trk_id, + trkseg_ids, + } => { + let trk = state + .files + .get(file_id) + .and_then(|file| file.trk.iter().find(|trk| trk.id == *trk_id)) + .ok_or(CommandError::NothingToDo)?; + let last = last_selected(trk.trkseg.iter().map(|seg| seg.id), trkseg_ids) + .ok_or(CommandError::NothingToDo)?; + SegmentsTo::Track(*file_id, *trk_id, Place::After(last)) + } + _ => return Err(CommandError::NothingToDo), + }; + transfer_segments(state, *file_id, *trk_id, trkseg_ids, cut, to)? + } + Selection::Waypoints { .. } | Selection::Waypoint { .. } => { + let (file_id, source) = match &clipboard.selection { + Selection::Waypoints { file_id } => (*file_id, None), + Selection::Waypoint { file_id, wpt_ids } => (*file_id, Some(wpt_ids)), + _ => unreachable!(), + }; + let (to, place) = match &*state.selection { + Selection::File { file_ids } => ( + last_selected(state.order.0.iter().copied(), file_ids) + .ok_or(CommandError::NothingToDo)?, + Place::End, + ), + Selection::Waypoints { file_id } => (*file_id, Place::End), + Selection::Waypoint { file_id, wpt_ids } => { + let file = state.files.get(file_id).ok_or(CommandError::NothingToDo)?; + let last = last_selected(waypoint_ids(file), wpt_ids) + .ok_or(CommandError::NothingToDo)?; + (*file_id, Place::After(last)) + } + _ => return Err(CommandError::NothingToDo), + }; + transfer_waypoints(state, file_id, source, cut, to, place)? } Selection::Empty => return Err(CommandError::NothingToDo), }; @@ -58,382 +124,14 @@ fn last_selected( all.filter(|id| selected.contains(id)).last() } -fn waypoint_ids(file: &File) -> impl Iterator + '_ { - file.wpt - .iter() - .flat_map(|chunk| &chunk.wpt) - .map(|wpt| wpt.id) -} - -fn file_mut<'a>(state: &'a mut State, id: FileId) -> Result<&'a mut File, CommandError> { - state - .files - .get_mut(&id) - .map(Rc::make_mut) - .ok_or(CommandError::NothingToDo) -} - -/// Adds the file to the files, at the end of the order. -fn add_file(state: &mut State, file: File) -> FileId { - let id = file.id; - state.files.insert(id, Rc::new(file)); - state.order.0.push(id); - id -} - -fn new_file(name: &str, trk: Vec) -> File { - let mut file = File { - trk, - ..Default::default() - }; - file.info.name = name.to_owned(); - file -} - -fn paste_files( - state: &mut State, - source: &HashSet, - cut: bool, -) -> Result { - let ids: Vec = state - .order - .0 - .iter() - .copied() - .filter(|id| source.contains(id) && state.files.contains_key(id)) - .collect(); - if ids.is_empty() { - return Err(CommandError::NothingToDo); - } - - let pasted = if cut { - // moved to the end of the list - state.order.move_files(&ids, usize::MAX); - ids - } else { - let copies: Vec = ids.iter().map(|id| copy_file(&state.files[id])).collect(); - copies - .into_iter() - .map(|file| add_file(state, file)) - .collect() - }; - Ok(Selection::File { - file_ids: pasted.into_iter().collect(), - }) -} - -fn paste_tracks( - state: &mut State, - file_id: FileId, - source: &HashSet, - cut: bool, -) -> Result { - let source_file = state.files.get(&file_id).ok_or(CommandError::NothingToDo)?; - let source_name = source_file.info.name.clone(); - let tracks: Vec = source_file - .trk - .iter() - .filter(|trk| source.contains(&trk.id)) - .cloned() - .collect(); - if tracks.is_empty() { - return Err(CommandError::NothingToDo); - } - - // where to paste, checked before anything is changed - enum To { - Files, - End(FileId), - After(FileId, TrackId), - } - let to = match &*state.selection { - Selection::Empty => To::Files, - Selection::File { file_ids } => To::End( - last_selected(state.order.0.iter().copied(), file_ids) - .ok_or(CommandError::NothingToDo)?, - ), - Selection::Track { file_id, trk_ids } => { - let file = state.files.get(file_id).ok_or(CommandError::NothingToDo)?; - To::After( - *file_id, - last_selected(file.trk.iter().map(|trk| trk.id), trk_ids) - .ok_or(CommandError::NothingToDo)?, - ) - } - _ => return Err(CommandError::NothingToDo), - }; - if let To::End(id) | To::After(id, _) = &to - && !state.files.contains_key(id) - { - return Err(CommandError::NothingToDo); - } - - if cut { - file_mut(state, file_id)? - .trk - .retain(|trk| !source.contains(&trk.id)); - } - let tracks: Vec = if cut { - tracks - } else { - tracks.iter().map(copy_track).collect() - }; - let ids: HashSet = tracks.iter().map(|trk| trk.id).collect(); - - Ok(match to { - To::Files => { - let files = tracks - .into_iter() - .map(|track| { - let name = track - .info - .name - .clone() - .unwrap_or_else(|| source_name.clone()); - new_file(&name, vec![track]) - }) - .collect::>(); - Selection::File { - file_ids: files - .into_iter() - .map(|file| add_file(state, file)) - .collect(), - } - } - To::End(id) => { - file_mut(state, id)?.trk.extend(tracks); - Selection::Track { - file_id: id, - trk_ids: ids, - } - } - To::After(id, after) => { - let file = file_mut(state, id)?; - // the track may have just been cut away - let at = file - .trk - .iter() - .position(|trk| trk.id == after) - .map_or(file.trk.len(), |i| i + 1); - file.trk.splice(at..at, tracks); - Selection::Track { - file_id: id, - trk_ids: ids, - } - } - }) -} - -fn paste_segments( - state: &mut State, - file_id: FileId, - trk_id: TrackId, - source: &HashSet, - cut: bool, -) -> Result { - let source_file = state.files.get(&file_id).ok_or(CommandError::NothingToDo)?; - let source_track = source_file - .trk - .iter() - .find(|trk| trk.id == trk_id) - .ok_or(CommandError::NothingToDo)?; - let source_name = source_track - .info - .name - .clone() - .unwrap_or_else(|| source_file.info.name.clone()); - let segments: Vec = source_track - .trkseg - .iter() - .filter(|seg| source.contains(&seg.id)) - .cloned() - .collect(); - if segments.is_empty() { - return Err(CommandError::NothingToDo); - } - - enum To { - Files, - Tracks(FileId), - End(FileId, TrackId), - After(FileId, TrackId, TrackSegmentId), - } - let to = match &*state.selection { - Selection::Empty => To::Files, - Selection::File { file_ids } => To::Tracks( - last_selected(state.order.0.iter().copied(), file_ids) - .ok_or(CommandError::NothingToDo)?, - ), - Selection::Track { file_id, trk_ids } => { - let file = state.files.get(file_id).ok_or(CommandError::NothingToDo)?; - To::End( - *file_id, - last_selected(file.trk.iter().map(|trk| trk.id), trk_ids) - .ok_or(CommandError::NothingToDo)?, - ) - } - Selection::TrackSegment { - file_id, - trk_id, - trkseg_ids, - } => { - let trk = state - .files - .get(file_id) - .and_then(|file| file.trk.iter().find(|trk| trk.id == *trk_id)) - .ok_or(CommandError::NothingToDo)?; - To::After( - *file_id, - *trk_id, - last_selected(trk.trkseg.iter().map(|seg| seg.id), trkseg_ids) - .ok_or(CommandError::NothingToDo)?, - ) - } - _ => return Err(CommandError::NothingToDo), - }; - if let To::Tracks(id) = &to - && !state.files.contains_key(id) - { - return Err(CommandError::NothingToDo); - } - - if cut { - let file = file_mut(state, file_id)?; - if let Some(trk) = file.trk.iter_mut().find(|trk| trk.id == trk_id) { - trk.trkseg.retain(|seg| !source.contains(&seg.id)); - } - } - let segments: Vec = if cut { - segments - } else { - segments.iter().map(copy_segment).collect() - }; - let ids: HashSet = segments.iter().map(|seg| seg.id).collect(); - - // a segment on its own needs a track - let own_track = |segment: TrackSegment| Track { - trkseg: vec![segment], - ..Default::default() - }; - let in_track = |state: &mut State, - file_id: FileId, - trk_id: TrackId, - after: Option, - segments: Vec| - -> Result<(), CommandError> { - let trk = file_mut(state, file_id)? - .trk - .iter_mut() - .find(|trk| trk.id == trk_id) - .ok_or(CommandError::NothingToDo)?; - // the segment may have just been cut away - let at = after - .and_then(|after| trk.trkseg.iter().position(|seg| seg.id == after)) - .map_or(trk.trkseg.len(), |i| i + 1); - trk.trkseg.splice(at..at, segments); - Ok(()) - }; - - Ok(match to { - To::Files => { - let files = segments - .into_iter() - .map(|segment| new_file(&source_name, vec![own_track(segment)])) - .collect::>(); - Selection::File { - file_ids: files - .into_iter() - .map(|file| add_file(state, file)) - .collect(), - } - } - To::Tracks(id) => { - let tracks: Vec = segments.into_iter().map(own_track).collect(); - let trk_ids = tracks.iter().map(|trk| trk.id).collect(); - file_mut(state, id)?.trk.extend(tracks); - Selection::Track { - file_id: id, - trk_ids, - } - } - To::End(file_id, trk_id) => { - in_track(state, file_id, trk_id, None, segments)?; - Selection::TrackSegment { - file_id, - trk_id, - trkseg_ids: ids, - } - } - To::After(file_id, trk_id, after) => { - in_track(state, file_id, trk_id, Some(after), segments)?; - Selection::TrackSegment { - file_id, - trk_id, - trkseg_ids: ids, - } - } - }) -} - -fn paste_waypoints( - state: &mut State, - file_id: FileId, - source: Option<&HashSet>, - cut: bool, -) -> Result { - // all the waypoints of the file when no waypoints are given - let selected = |wpt: &Waypoint| source.is_none_or(|ids| ids.contains(&wpt.id)); - let source_file = state.files.get(&file_id).ok_or(CommandError::NothingToDo)?; - let waypoints: Vec = source_file - .wpt - .iter() - .flat_map(|chunk| &chunk.wpt) - .filter(|wpt| selected(wpt)) - .cloned() - .collect(); - if waypoints.is_empty() { - return Err(CommandError::NothingToDo); - } - - let (to_file, after) = match &*state.selection { - Selection::File { file_ids } => ( - last_selected(state.order.0.iter().copied(), file_ids) - .ok_or(CommandError::NothingToDo)?, - None, - ), - Selection::Waypoints { file_id } => (*file_id, None), - Selection::Waypoint { file_id, wpt_ids } => { - let file = state.files.get(file_id).ok_or(CommandError::NothingToDo)?; - (*file_id, last_selected(waypoint_ids(file), wpt_ids)) - } - _ => return Err(CommandError::NothingToDo), - }; - if !state.files.contains_key(&to_file) { - return Err(CommandError::NothingToDo); - } - - if cut { - edit_waypoint_chunks(file_mut(state, file_id)?, selected, |wpts| { - wpts.retain(|wpt| !selected(wpt)); - true - }); - } - let waypoints: Vec = if cut { - waypoints - } else { - waypoints.iter().map(copy_waypoint).collect() - }; - let ids = waypoints.iter().map(|wpt| wpt.id).collect(); - insert_waypoints(file_mut(state, to_file)?, after, waypoints); - Ok(Selection::Waypoint { - file_id: to_file, - wpt_ids: ids, - }) -} - #[cfg(test)] mod tests { - use crate::{Clipboard, TrackInfo, engine::command::fixture::Fixture}; + use std::rc::Rc; + + use crate::{ + Clipboard, File, FileId, Track, TrackId, TrackInfo, TrackSegment, TrackSegmentId, Waypoint, + WaypointId, engine::command::fixture::Fixture, new_file, + }; use super::*; diff --git a/gpx-rs/engine/src/engine/command/file/transfer.rs b/gpx-rs/engine/src/engine/command/file/transfer.rs new file mode 100644 index 000000000..e38452dac --- /dev/null +++ b/gpx-rs/engine/src/engine/command/file/transfer.rs @@ -0,0 +1,358 @@ +//! Copying or moving elements of the files to a place of the file tree: what paste and move are +//! made of. The elements come from where they are found by id, and the place says where they +//! go among the elements already there. + +use std::{collections::HashSet, rc::Rc}; + +use crate::{ + CommandError, File, FileId, Selection, State, Track, TrackId, TrackSegment, TrackSegmentId, + Waypoint, WaypointId, copy_file, copy_segment, copy_track, copy_waypoint, edit_waypoint_chunks, + insert_waypoints_at, +}; + +/// Where elements are put, among the others of the same list. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Place { + /// After all of them. + End, + /// Right after the one with this id, or at the end if it is not there (anymore). + After(T), + /// At this position (at the end if it is past it). + Index(usize), +} + +impl Place { + /// The position in the list of `ids`. + pub fn position(&self, ids: &[T]) -> usize { + match self { + Place::End => ids.len(), + Place::After(id) => ids + .iter() + .position(|other| other == id) + .map_or(ids.len(), |i| i + 1), + Place::Index(index) => (*index).min(ids.len()), + } + } +} + +pub fn waypoint_ids(file: &File) -> impl Iterator + '_ { + file.wpt + .iter() + .flat_map(|chunk| &chunk.wpt) + .map(|wpt| wpt.id) +} + +fn file_mut<'a>(state: &'a mut State, id: FileId) -> Result<&'a mut File, CommandError> { + state + .files + .get_mut(&id) + .map(Rc::make_mut) + .ok_or(CommandError::NothingToDo) +} + +pub fn new_file(name: &str, trk: Vec) -> File { + let mut file = File { + trk, + ..Default::default() + }; + file.info.name = name.to_owned(); + file +} + +/// Adds the files to the state, at `place` in the order of the files. +fn add_files(state: &mut State, files: Vec, place: Place) -> HashSet { + let at = place.position(&state.order.0); + let ids: Vec = files.iter().map(|file| file.id).collect(); + for file in files { + state.files.insert(file.id, Rc::new(file)); + } + state.order.0.splice(at..at, ids.iter().copied()); + ids.into_iter().collect() +} + +/// Copies the files, or moves them in the order of the files when `cut`. +pub fn transfer_files( + state: &mut State, + source: &HashSet, + cut: bool, + place: Place, +) -> Result { + let ids: Vec = state + .order + .0 + .iter() + .copied() + .filter(|id| source.contains(id) && state.files.contains_key(id)) + .collect(); + if ids.is_empty() { + return Err(CommandError::NothingToDo); + } + + let file_ids = if cut { + // the position is among the files that stay + let others: Vec = state + .order + .0 + .iter() + .copied() + .filter(|id| !ids.contains(id)) + .collect(); + state.order.move_files(&ids, place.position(&others)); + ids.into_iter().collect() + } else { + let copies = ids.iter().map(|id| copy_file(&state.files[id])).collect(); + add_files(state, copies, place) + }; + Ok(Selection::File { file_ids }) +} + +/// Where tracks go. +#[derive(Debug, Clone, Copy)] +pub enum TracksTo { + /// Each track becomes a new file, named like the track, or like the file it comes from. + Files(Place), + /// The tracks go in a file. + File(FileId, Place), +} + +/// Copies the tracks, or moves them (with their ids) when `cut`. +pub fn transfer_tracks( + state: &mut State, + file_id: FileId, + source: &HashSet, + cut: bool, + to: TracksTo, +) -> Result { + let source_file = state.files.get(&file_id).ok_or(CommandError::NothingToDo)?; + let source_name = source_file.info.name.clone(); + let tracks: Vec = source_file + .trk + .iter() + .filter(|trk| source.contains(&trk.id)) + .cloned() + .collect(); + if tracks.is_empty() { + return Err(CommandError::NothingToDo); + } + if let TracksTo::File(id, _) = to + && !state.files.contains_key(&id) + { + return Err(CommandError::NothingToDo); + } + + if cut { + file_mut(state, file_id)? + .trk + .retain(|trk| !source.contains(&trk.id)); + } + let tracks: Vec = if cut { + tracks + } else { + tracks.iter().map(copy_track).collect() + }; + let ids: HashSet = tracks.iter().map(|trk| trk.id).collect(); + + Ok(match to { + TracksTo::Files(place) => { + let files = tracks + .into_iter() + .map(|track| { + let name = track + .info + .name + .clone() + .unwrap_or_else(|| source_name.clone()); + new_file(&name, vec![track]) + }) + .collect(); + Selection::File { + file_ids: add_files(state, files, place), + } + } + TracksTo::File(id, place) => { + let file = file_mut(state, id)?; + let existing: Vec = file.trk.iter().map(|trk| trk.id).collect(); + let at = place.position(&existing); + file.trk.splice(at..at, tracks); + Selection::Track { + file_id: id, + trk_ids: ids, + } + } + }) +} + +/// Where segments go. +#[derive(Debug, Clone, Copy)] +pub enum SegmentsTo { + /// Each segment becomes a new file with a track, named like the track it comes from. + Files(Place), + /// Each segment becomes a new track of the file. + Tracks(FileId, Place), + /// The segments go in a track of a file. + Track(FileId, TrackId, Place), +} + +/// Copies the segments of a track, or moves them (with their ids) when `cut`. +pub fn transfer_segments( + state: &mut State, + file_id: FileId, + trk_id: TrackId, + source: &HashSet, + cut: bool, + to: SegmentsTo, +) -> Result { + let source_file = state.files.get(&file_id).ok_or(CommandError::NothingToDo)?; + let source_track = source_file + .trk + .iter() + .find(|trk| trk.id == trk_id) + .ok_or(CommandError::NothingToDo)?; + let source_name = source_track + .info + .name + .clone() + .unwrap_or_else(|| source_file.info.name.clone()); + let segments: Vec = source_track + .trkseg + .iter() + .filter(|seg| source.contains(&seg.id)) + .cloned() + .collect(); + if segments.is_empty() { + return Err(CommandError::NothingToDo); + } + let destination_exists = match to { + SegmentsTo::Files(_) => true, + SegmentsTo::Tracks(id, _) => state.files.contains_key(&id), + SegmentsTo::Track(id, trk, _) => state + .files + .get(&id) + .is_some_and(|file| file.trk.iter().any(|t| t.id == trk)), + }; + if !destination_exists { + return Err(CommandError::NothingToDo); + } + + if cut { + let file = file_mut(state, file_id)?; + if let Some(trk) = file.trk.iter_mut().find(|trk| trk.id == trk_id) { + trk.trkseg.retain(|seg| !source.contains(&seg.id)); + } + } + let segments: Vec = if cut { + segments + } else { + segments.iter().map(copy_segment).collect() + }; + let ids: HashSet = segments.iter().map(|seg| seg.id).collect(); + + // a segment on its own needs a track + let own_track = |segment: TrackSegment| Track { + trkseg: vec![segment], + ..Default::default() + }; + + Ok(match to { + SegmentsTo::Files(place) => { + let files = segments + .into_iter() + .map(|segment| new_file(&source_name, vec![own_track(segment)])) + .collect(); + Selection::File { + file_ids: add_files(state, files, place), + } + } + SegmentsTo::Tracks(id, place) => { + let tracks: Vec = segments.into_iter().map(own_track).collect(); + let trk_ids = tracks.iter().map(|trk| trk.id).collect(); + let file = file_mut(state, id)?; + let existing: Vec = file.trk.iter().map(|trk| trk.id).collect(); + let at = place.position(&existing); + file.trk.splice(at..at, tracks); + Selection::Track { + file_id: id, + trk_ids, + } + } + SegmentsTo::Track(id, trk_id, place) => { + let trk = file_mut(state, id)? + .trk + .iter_mut() + .find(|trk| trk.id == trk_id) + .ok_or(CommandError::NothingToDo)?; + let existing: Vec = trk.trkseg.iter().map(|seg| seg.id).collect(); + let at = place.position(&existing); + trk.trkseg.splice(at..at, segments); + Selection::TrackSegment { + file_id: id, + trk_id, + trkseg_ids: ids, + } + } + }) +} + +/// Copies waypoints of a file (all of them when `source` is `None`) to `place` among the +/// waypoints of the file `to`, or moves them (with their ids) when `cut`. +pub fn transfer_waypoints( + state: &mut State, + file_id: FileId, + source: Option<&HashSet>, + cut: bool, + to: FileId, + place: Place, +) -> Result { + let selected = |wpt: &Waypoint| source.is_none_or(|ids| ids.contains(&wpt.id)); + let source_file = state.files.get(&file_id).ok_or(CommandError::NothingToDo)?; + let waypoints: Vec = source_file + .wpt + .iter() + .flat_map(|chunk| &chunk.wpt) + .filter(|wpt| selected(wpt)) + .cloned() + .collect(); + if waypoints.is_empty() || !state.files.contains_key(&to) { + return Err(CommandError::NothingToDo); + } + + if cut { + edit_waypoint_chunks(file_mut(state, file_id)?, selected, |wpts| { + wpts.retain(|wpt| !selected(wpt)); + true + }); + } + let waypoints: Vec = if cut { + waypoints + } else { + waypoints.iter().map(copy_waypoint).collect() + }; + let ids = waypoints.iter().map(|wpt| wpt.id).collect(); + let file = file_mut(state, to)?; + let existing: Vec = waypoint_ids(file).collect(); + insert_waypoints_at(file, place.position(&existing), waypoints); + Ok(Selection::Waypoint { + file_id: to, + wpt_ids: ids, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_place_position() { + let ids = [1, 2, 3]; + assert_eq!(Place::End.position(&ids), 3); + assert_eq!(Place::After(1).position(&ids), 1); + assert_eq!(Place::After(3).position(&ids), 3); + // not there: at the end + assert_eq!(Place::After(9).position(&ids), 3); + assert_eq!(Place::Index(0).position(&ids), 0); + assert_eq!(Place::Index(2).position(&ids), 2); + assert_eq!(Place::Index(10).position(&ids), 3); + assert_eq!(Place::::End.position(&[]), 0); + assert_eq!(Place::::Index(4).position(&[]), 0); + } +} diff --git a/gpx-rs/engine/src/engine/command/pattern/insert_waypoints.rs b/gpx-rs/engine/src/engine/command/pattern/insert_waypoints.rs index f815e189e..d49f34d76 100644 --- a/gpx-rs/engine/src/engine/command/pattern/insert_waypoints.rs +++ b/gpx-rs/engine/src/engine/command/pattern/insert_waypoints.rs @@ -5,9 +5,25 @@ use crate::{File, Waypoint, WaypointChunk, WaypointId}; /// Inserts waypoints in the file, right after the waypoint `after`, or at the end if there is /// none (or if it is not in the file). /// -/// Only the chunk holding `after` is cut (when waypoints follow it), all the other chunks are -/// kept as they are. +/// See [`insert_waypoints_at`]. pub fn insert_waypoints(file: &mut File, after: Option, waypoints: Vec) { + let index = after + .and_then(|after| { + file.wpt + .iter() + .flat_map(|chunk| &chunk.wpt) + .position(|wpt| wpt.id == after) + }) + .map_or(usize::MAX, |i| i + 1); + insert_waypoints_at(file, index, waypoints); +} + +/// Inserts waypoints in the file, so that the first one is at `index` among the waypoints of +/// the file (at the end if the index is past it). +/// +/// Only the chunk holding the waypoint that was at `index` is cut (when the waypoints are not +/// inserted between two chunks), all the other chunks are kept as they are. +pub fn insert_waypoints_at(file: &mut File, index: usize, waypoints: Vec) { if waypoints.is_empty() { return; } @@ -24,36 +40,32 @@ pub fn insert_waypoints(file: &mut File, after: Option, waypoints: V inserted.push(Rc::new(chunk)); } - let position = after.and_then(|after| { - file.wpt.iter().enumerate().find_map(|(i, chunk)| { - chunk - .wpt - .iter() - .position(|wpt| wpt.id == after) - .map(|j| (i, j + 1)) - }) + // the chunk holding the waypoint at `index`, and the position of that waypoint in it + let mut start = 0; + let position = file.wpt.iter().enumerate().find_map(|(i, chunk)| { + let offset = index + .checked_sub(start) + .filter(|offset| *offset < chunk.wpt.len()); + start += chunk.wpt.len(); + offset.map(|offset| (i, offset)) }); match position { None => file.wpt.extend(inserted), - Some((i, split)) => { + Some((i, 0)) => { + file.wpt.splice(i..i, inserted); + } + Some((i, offset)) => { let cut = &file.wpt[i]; - let replacement = if split == cut.wpt.len() { - let mut chunks = vec![cut.clone()]; - chunks.extend(inserted); - chunks - } else { - let part = |wpt: &[Waypoint]| { - Rc::new(WaypointChunk { - wpt: wpt.to_vec(), - ..Default::default() - }) - }; - let mut chunks = vec![part(&cut.wpt[..split])]; - chunks.extend(inserted); - chunks.push(part(&cut.wpt[split..])); - chunks + let part = |wpt: &[Waypoint]| { + Rc::new(WaypointChunk { + wpt: wpt.to_vec(), + ..Default::default() + }) }; - file.wpt.splice(i..=i, replacement); + let mut chunks = vec![part(&cut.wpt[..offset])]; + chunks.extend(inserted); + chunks.push(part(&cut.wpt[offset..])); + file.wpt.splice(i..=i, chunks); } } file.wpt_rev_id = Default::default(); @@ -155,4 +167,52 @@ mod tests { assert_eq!(file.wpt.len(), 3); assert!(file.wpt[..2].iter().all(|chunk| chunk.is_full())); } + + #[test] + fn test_insert_at_an_index() { + // at the start + let (mut file, before) = file_with(&[2, 1]); + let first = file.wpt[0].clone(); + let one = waypoint(); + let one_id = one.id; + insert_waypoints_at(&mut file, 0, vec![one]); + assert_eq!(ids(&file), [vec![one_id], before.clone()].concat()); + // the existing chunks are kept + assert_eq!(file.wpt.len(), 3); + assert!(Rc::ptr_eq(&file.wpt[1], &first)); + + // between two chunks + let (mut file, before) = file_with(&[2, 1]); + let (first, last) = (file.wpt[0].clone(), file.wpt[1].clone()); + let one = waypoint(); + let one_id = one.id; + insert_waypoints_at(&mut file, 2, vec![one]); + assert_eq!(ids(&file), [&before[..2], &[one_id], &before[2..]].concat()); + assert_eq!(file.wpt.len(), 3); + assert!(Rc::ptr_eq(&file.wpt[0], &first) && Rc::ptr_eq(&file.wpt[2], &last)); + + // inside a chunk + let (mut file, before) = file_with(&[3]); + let one = waypoint(); + let one_id = one.id; + insert_waypoints_at(&mut file, 1, vec![one]); + assert_eq!(ids(&file), [&before[..1], &[one_id], &before[1..]].concat()); + assert_eq!(file.wpt.len(), 3); + + // at the end, or past it + for index in [3, 100] { + let (mut file, before) = file_with(&[3]); + let one = waypoint(); + let one_id = one.id; + insert_waypoints_at(&mut file, index, vec![one]); + assert_eq!(ids(&file), [before, vec![one_id]].concat()); + } + + // in a file without waypoints + let mut file = File::default(); + let one = waypoint(); + let one_id = one.id; + insert_waypoints_at(&mut file, 0, vec![one]); + assert_eq!(ids(&file), vec![one_id]); + } } diff --git a/gpx-rs/engine/src/engine/engine.rs b/gpx-rs/engine/src/engine/engine.rs index 379b07131..65a1f9944 100644 --- a/gpx-rs/engine/src/engine/engine.rs +++ b/gpx-rs/engine/src/engine/engine.rs @@ -1021,4 +1021,44 @@ mod tests { assert!(engine.execute(Action::Undo)); assert!(!engine.can_undo()); } + + #[test] + fn test_move_elements_is_an_edit_and_selects_what_moved() { + use crate::{Move, MoveTarget}; + + let mut engine = Engine::default(); + let a = file_with_tracks(&mut engine, "a", 3); + let tracks = track_ids(&engine, a); + let moved = Selection::Track { + file_id: a, + trk_ids: [tracks[2]].into(), + }; + assert!(edit( + &mut engine, + Command::Move(Move { + what: moved.clone(), + to: MoveTarget::Tracks { + file_id: a, + index: 0 + }, + }) + )); + assert_eq!(track_ids(&engine, a), vec![tracks[2], tracks[0], tracks[1]]); + assert_eq!(engine.selection(), &moved); + + // invalid moves change nothing + assert!(!edit( + &mut engine, + Command::Move(Move { + what: moved, + to: MoveTarget::Waypoints { + file_id: a, + index: 0 + }, + }) + )); + + assert!(engine.execute(Action::Undo)); + assert_eq!(track_ids(&engine, a), tracks); + } } diff --git a/gpx-rs/wasm/src/lib.rs b/gpx-rs/wasm/src/lib.rs index 7610d9df4..f6c0379cf 100644 --- a/gpx-rs/wasm/src/lib.rs +++ b/gpx-rs/wasm/src/lib.rs @@ -384,6 +384,107 @@ pub fn can_paste() -> bool { with_engine(|e| e.can_paste()).unwrap_or(false) } +// Moving elements (drag and drop) +// +// Unlike the other functions, the arguments are objects, as they are in the `Selection` and +// `MoveTarget` types: it happens once per drop, and they are much clearer than a list of ids. + +fn property(object: &JsValue, key: &str) -> Option { + Reflect::get(object, &key.into()) + .ok() + .filter(|value| !value.is_undefined()) +} + +fn uuid_property(object: &JsValue, key: &str) -> Option { + uuid::Uuid::parse_str(&property(object, key)?.as_string()?).ok() +} + +fn uuids_property(object: &JsValue, key: &str) -> Option> { + Array::from(&property(object, key)?) + .iter() + .map(|id| uuid::Uuid::parse_str(&id.as_string()?).ok()) + .collect() +} + +/// The inverse of `selection_object`. +fn parse_selection(value: &JsValue) -> Option { + use engine::Selection as S; + let file_id = || uuid_property(value, "fileId").map(FileId); + Some(match property(value, "type")?.as_string()?.as_str() { + "empty" => S::Empty, + "file" => S::File { + file_ids: uuids_property(value, "fileIds")? + .into_iter() + .map(FileId) + .collect(), + }, + "track" => S::Track { + file_id: file_id()?, + trk_ids: uuids_property(value, "trackIds")? + .into_iter() + .map(engine::TrackId) + .collect(), + }, + "segment" => S::TrackSegment { + file_id: file_id()?, + trk_id: engine::TrackId(uuid_property(value, "trackId")?), + trkseg_ids: uuids_property(value, "segmentIds")? + .into_iter() + .map(engine::TrackSegmentId) + .collect(), + }, + "waypoints" => S::Waypoints { + file_id: file_id()?, + }, + "waypoint" => S::Waypoint { + file_id: file_id()?, + wpt_ids: uuids_property(value, "waypointIds")? + .into_iter() + .map(engine::WaypointId) + .collect(), + }, + _ => return None, + }) +} + +fn parse_move_target(value: &JsValue) -> Option { + use engine::MoveTarget as T; + // a negative index is the start, an infinite one is the end + let index = property(value, "index")?.as_f64()?.max(0.0) as usize; + let file_id = || uuid_property(value, "fileId").map(FileId); + Some(match property(value, "type")?.as_string()?.as_str() { + "files" => T::Files { index }, + "tracks" => T::Tracks { + file_id: file_id()?, + index, + }, + "segments" => T::Segments { + file_id: file_id()?, + trk_id: engine::TrackId(uuid_property(value, "trackId")?), + index, + }, + "waypoints" => T::Waypoints { + file_id: file_id()?, + index, + }, + _ => return None, + }) +} + +/// Moves elements (given as a `Selection`) to a place of the file tree, keeping their ids, like +/// dragging and dropping them does. The moved elements are selected. +/// +/// Files can only go among the files; tracks among the files (each becomes a file) or the tracks +/// of a file; segments among the files (each becomes a file), the tracks of a file (each becomes +/// a track) or the segments of a track; waypoints, or all the waypoints of a file, among the +/// waypoints of a file. +#[wasm_bindgen] +pub fn move_elements(what: Selection, to: MoveTarget) -> bool { + parse_selection(&what) + .zip(parse_move_target(&to)) + .is_some_and(|(what, to)| edit(Command::Move(engine::Move { what, to }))) +} + // Selection /// Selects files. `file_ids_bytes`: concatenated 16-byte UUIDs. Unknown files are ignored, and @@ -500,6 +601,15 @@ export type Selection = | { type: 'segment'; fileId: string; trackId: string; segmentIds: string[] } | { type: 'waypoints'; fileId: string } | { type: 'waypoint'; fileId: string; waypointIds: string[] }; +/** + * Where moved elements go: a list of the file tree and the position in it, counted among the + * elements of the list that are not moved. + */ +export type MoveTarget = + | { type: 'files'; index: number } + | { type: 'tracks'; fileId: string; index: number } + | { type: 'segments'; fileId: string; trackId: string; index: number } + | { type: 'waypoints'; fileId: string; index: number }; /** What was copied or cut, to be pasted. */ export interface Clipboard { selection: Selection; @@ -580,6 +690,8 @@ export interface WaypointNode { extern "C" { #[wasm_bindgen(typescript_type = "Selection")] pub type Selection; + #[wasm_bindgen(typescript_type = "MoveTarget")] + pub type MoveTarget; #[wasm_bindgen(typescript_type = "Clipboard | undefined")] pub type Clipboard; #[wasm_bindgen(typescript_type = "FilesUpdate")] diff --git a/website/src/lib/components/file-list/sortable-file-list.ts b/website/src/lib/components/file-list/sortable-file-list.ts index b5e2a68dc..b168448cb 100644 --- a/website/src/lib/components/file-list/sortable-file-list.ts +++ b/website/src/lib/components/file-list/sortable-file-list.ts @@ -3,13 +3,8 @@ import Sortable, { type Direction } from 'sortablejs/Sortable'; import { ListLevel } from './file-list'; import { get, writable } from 'svelte/store'; import { tick } from 'svelte'; -import { engine } from '$lib/engine'; +import { engine, type MoveTarget, type Selection } from '$lib/engine'; import { isSelected, type FileTreeNode } from '$lib/selection-helpers'; -// TODO moving elements between parents is not available in the engine yet -// import { ListItem, ListRootItem } from './file-list'; -// import { getFileIds, moveItems } from '$lib/logic/file-actions'; -// import { settings } from '$lib/logic/settings'; -// const { fileOrder } = settings; export const allowedMoves: Record = { [ListLevel.ROOT]: [], @@ -22,6 +17,9 @@ export const allowedMoves: Record = { export const dragging = writable(null); +/** The list of each container, to find the lists a drop is about from its event. */ +const lists = new WeakMap(); + /** * Makes a list of the file tree sortable and selectable. The elements of the list are the * children of `parent` (the files when `parent` is null), their ids are in their `data-id`. @@ -33,6 +31,8 @@ export class SortableFileList { private _sortable: Sortable | null = null; private _elements: { [id: string]: HTMLElement } = {}; private _updatingSelection: boolean = false; + /** The elements of the list when a drag from it started, to put them back when it ends. */ + private _snapshot: ChildNode[] = []; private _unsubscribes: (() => void)[] = []; constructor( @@ -45,16 +45,12 @@ export class SortableFileList { this._parent = parent; this._sortableLevel = sortableLevel; this._container = container; - // TODO only the order of the files can be changed for now: the other lists neither sort - // nor exchange elements (allowedMoves, to be used again once the engine can move elements) - const sortable = sortableLevel === ListLevel.FILE; this._sortable = Sortable.create(container, { group: { name: sortableLevel, - pull: sortable ? allowedMoves[sortableLevel] : false, - put: sortable ? [ListLevel.FILE] : false, + pull: allowedMoves[sortableLevel], + put: true, }, - sort: sortable, direction: orientation, forceAutoScrollFallback: true, multiDrag: true, @@ -64,14 +60,15 @@ export class SortableFileList { setTimeout(() => this.updateToSelection(e), 50), onDeselect: (e: Sortable.SortableEvent) => setTimeout(() => this.updateToSelection(e), 50), - onStart: () => dragging.set(sortableLevel), + onStart: () => { + // all the nodes, not only the elements: the comments are the anchors of the Svelte blocks + this._snapshot = Array.from(container.childNodes); + dragging.set(sortableLevel); + }, onEnd: () => dragging.set(null), onSort: (e: Sortable.SortableEvent) => this.onSort(e), }); - Object.defineProperty(this._sortable, '_waypointRoot', { - value: waypointRoot, - writable: true, - }); + lists.set(container, this); this._unsubscribes.push( engine.selection.subscribe(() => tick().then(() => this.updateFromSelection())) @@ -109,66 +106,92 @@ export class SortableFileList { } } + /** The elements of the list in the selection form. */ + private selectionOf(ids: string[]): Selection | undefined { + const parent = this._parent; + switch (this._sortableLevel) { + case ListLevel.FILE: + return { type: 'file', fileIds: ids }; + case ListLevel.TRACK: + return parent ? { type: 'track', fileId: parent.fileId, trackIds: ids } : undefined; + case ListLevel.SEGMENT: + return parent?.type === 'track' + ? { + type: 'segment', + fileId: parent.fileId, + trackId: parent.trackId, + segmentIds: ids, + } + : undefined; + case ListLevel.WAYPOINTS: + return parent ? { type: 'waypoints', fileId: parent.fileId } : undefined; + case ListLevel.WAYPOINT: + return parent + ? { type: 'waypoint', fileId: parent.fileId, waypointIds: ids } + : undefined; + } + } + + /** A position in the list, as a place to move elements to. */ + private targetAt(index: number): MoveTarget | undefined { + const parent = this._parent; + switch (this._sortableLevel) { + case ListLevel.FILE: + return { type: 'files', index }; + case ListLevel.TRACK: + return parent ? { type: 'tracks', fileId: parent.fileId, index } : undefined; + case ListLevel.SEGMENT: + return parent?.type === 'track' + ? { type: 'segments', fileId: parent.fileId, trackId: parent.trackId, index } + : undefined; + case ListLevel.WAYPOINTS: + // the list holds the node standing for the waypoints: they go after the others + return parent + ? { type: 'waypoints', fileId: parent.fileId, index: Infinity } + : undefined; + case ListLevel.WAYPOINT: + return parent ? { type: 'waypoints', fileId: parent.fileId, index } : undefined; + } + } + onSort(e: Sortable.SortableEvent) { - this.updateToFileOrder(); + const from = lists.get(e.from); + const to = lists.get(e.to); + if (!from || !to) { + return; + } - // TODO moving elements between parents: the engine has no command for it yet, the code - // below worked on the previous implementation - // onSort(e: Sortable.SortableEvent) { - // this.updateToFileOrder(); + if (from === to && this._sortableLevel === ListLevel.FILE) { + // the order of the files is the one of the list + this.updateToFileOrder(); + return; + } - // const from = Sortable.get(e.from); - // const to = Sortable.get(e.to); + // The event is triggered on the source and on the destination list: handle it once. + // Nothing to move in the list of the single node of the waypoints. + if (to !== this || (from === to && this._sortableLevel === ListLevel.WAYPOINTS)) { + return; + } - // if (!from || !to) { - // return; - // } + const elements: HTMLElement[] = e.items.length > 0 ? e.items : [e.item]; + const ids = elements + .map((element) => element.getAttribute('data-id')) + .filter((id): id is string => id !== null); + const newIndices = ( + e.newIndicies.length > 0 + ? e.newIndicies.map((i: { index: number }) => i.index) + : [e.newIndex] + ).filter((index: number | undefined): index is number => index !== undefined && index >= 0); + const what = from.selectionOf(ids); + const target = to.targetAt(newIndices.length > 0 ? Math.min(...newIndices) : Infinity); - // let fromItem = from._item; - // let toItem = to._item; + // The list is rendered from the state of the engine: put the elements back where they + // were, the engine moves them for real. + from._snapshot.forEach((node) => e.from.appendChild(node)); - // if (this._item === toItem && !(fromItem instanceof ListRootItem)) { - // // Event is triggered on source and destination list, only handle it once - // let fromItems = []; - // let toItems = []; - - // if (from._waypointRoot) { - // fromItems = [fromItem.extend('waypoints')]; - // } else { - // let oldIndices: number[] = - // e.oldIndicies.length > 0 ? e.oldIndicies.map((i) => i.index) : [e.oldIndex]; - // oldIndices = oldIndices.filter((i) => i >= 0); - // oldIndices.sort((a, b) => a - b); - - // fromItems = oldIndices.map((i) => fromItem.extend(i)); - // } - - // if (from._waypointRoot && to._waypointRoot) { - // toItems = [toItem.extend('waypoints')]; - // } else { - // if (to._waypointRoot) { - // toItem = toItem.extend('waypoints'); - // } - - // let newIndices: number[] = - // e.newIndicies.length > 0 ? e.newIndicies.map((i) => i.index) : [e.newIndex]; - // newIndices = newIndices.filter((i) => i >= 0); - // newIndices.sort((a, b) => a - b); - - // if (toItem instanceof ListRootItem) { - // let newFileIds = getFileIds(newIndices.length); - // toItems = newIndices.map((i, index) => { - // get(fileOrder).splice(i, 0, newFileIds[index]); - // return this._item.extend(newFileIds[index]); - // }); - // } else { - // toItems = newIndices.map((i) => toItem.extend(i)); - // } - // } - - // moveItems(fromItem, toItem, fromItems, toItems); - // } - // } + if (what && target) { + engine.move(what, target); + } } updateFromSelection() { diff --git a/website/src/lib/engine.ts b/website/src/lib/engine.ts index a02066bf3..d6697e8b5 100644 --- a/website/src/lib/engine.ts +++ b/website/src/lib/engine.ts @@ -4,10 +4,11 @@ import { FileColorAllocator, normalizeColor } from '$lib/file-colors'; import { setHidden, type Visibility } from '$lib/file-visibility'; import { selectedElementIds, type FileTreeNode } from '$lib/selection-helpers'; import type { Feature, FeatureCollection, LineString, Point } from 'geojson'; -import type { Clipboard, GlobalStatistics, FileStructure, Selection } from 'gpx-rs'; +import type { Clipboard, GlobalStatistics, FileStructure, MoveTarget, Selection } from 'gpx-rs'; export type { Clipboard, + MoveTarget, GlobalStatistics, FileStructure, FilesUpdate, @@ -367,6 +368,20 @@ class Engine { return this.run((w) => w.paste()); } + /** + * Moves elements to a place of the file tree, keeping their ids, like dragging and dropping + * them does. The position of the target counts the elements of its list that are not moved. + * The moved elements are selected. + * + * Files go among the files; tracks among the files (each becomes a file) or the tracks of a + * file; segments among the files (each becomes a file), the tracks of a file (each becomes a + * track) or the segments of a track; waypoints, or all the waypoints of a file (the waypoints + * node), among the waypoints of a file. + */ + move(what: Selection, to: MoveTarget) { + return this.run((w) => w.move_elements(what, to)); + } + // Edits of the selection newTrack() {