diff --git a/gpx-rs/engine/src/engine/command/file/move_elements.rs b/gpx-rs/engine/src/engine/command/file/move_elements.rs index 0733fba04..fb4267125 100644 --- a/gpx-rs/engine/src/engine/command/file/move_elements.rs +++ b/gpx-rs/engine/src/engine/command/file/move_elements.rs @@ -1,6 +1,5 @@ use crate::{ - Apply, CommandError, FileId, Place, SegmentsTo, Selection, State, TrackId, TracksTo, - transfer_files, transfer_segments, transfer_tracks, transfer_waypoints, + Apply, Clipboard, CommandError, Destination, FileId, Place, Selection, State, TrackId, transfer, }; /// Where moved elements go: a list of the file tree, and the position in it. The position is @@ -38,67 +37,30 @@ pub struct Move { pub to: MoveTarget, } +impl From for Destination { + fn from(target: MoveTarget) -> Self { + match target { + MoveTarget::Files { index } => Destination::Files(Place::Index(index)), + MoveTarget::Tracks { file_id, index } => { + Destination::Tracks(file_id, Place::Index(index)) + } + MoveTarget::Segments { + file_id, + trk_id, + index, + } => Destination::Segments(file_id, trk_id, Place::Index(index)), + MoveTarget::Waypoints { file_id, index } => { + Destination::Waypoints(file_id, Place::Index(index)) + } + } + } +} + 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; + let clipboard = Clipboard::new(&self.what, state.files, &state.order.0, true) + .ok_or(CommandError::NothingToDo)?; + *state.selection = transfer(state, &clipboard.content, true, self.to.into())?; Ok(()) } } diff --git a/gpx-rs/engine/src/engine/command/file/paste.rs b/gpx-rs/engine/src/engine/command/file/paste.rs index 33447407e..e371798ba 100644 --- a/gpx-rs/engine/src/engine/command/file/paste.rs +++ b/gpx-rs/engine/src/engine/command/file/paste.rs @@ -1,11 +1,11 @@ use std::{collections::HashSet, hash::Hash}; use crate::{ - Apply, CommandError, Place, SegmentsTo, Selection, State, TracksTo, transfer_files, - transfer_segments, transfer_tracks, transfer_waypoints, waypoint_ids, + Apply, ClipboardContent, CommandError, Destination, FileId, Place, Selection, State, TrackId, + TrackSegmentId, WaypointId, transfer, waypoint_ids, }; -/// Pastes what was copied or cut (see [`crate::Clipboard`]), and empties the clipboard. +/// Pastes what was copied or cut (see [`crate::Clipboard`]). /// /// Where it goes depends on the selection, see [`crate::Clipboard::can_paste`]: /// - nothing selected: the files are copied, and tracks and segments become new files (named @@ -16,8 +16,12 @@ use crate::{ /// - a segment: the segments go after it in its track; /// - the waypoints or a waypoint: the waypoints are added to their file, after the waypoint. /// -/// What is cut is moved, with its ids, otherwise copies are made. The pasted elements are -/// selected. +/// Copies are made, with new ids, and the pasted elements are selected. What was cut is moved +/// instead, with its ids, the first time: what is still where it was is removed. +/// +/// What is pasted is always the elements as they were when they were copied or cut, even if they +/// were changed or deleted since. The clipboard is kept so that it can be pasted again: after +/// what was cut was moved it is not cut anymore, and the next pastes make copies. #[derive(Debug)] pub struct Paste; @@ -29,91 +33,97 @@ impl Apply for Paste { .filter(|clipboard| clipboard.can_paste(state.selection)) .ok_or(CommandError::NothingToDo)?; let cut = clipboard.cut; - let next = match &clipboard.selection { - 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)? - } + // always the elements as they were when they were copied or cut + let to = destination(state, &clipboard.content)?; + let next = transfer(state, &clipboard.content, cut, to)?; + + if cut && let Some(clipboard) = state.clipboard.as_mut() { + // moved: the next pastes make copies + clipboard.cut = false; + } + *state.selection = next; + Ok(()) + } +} + +fn track_ids_of(state: &State, file_id: FileId) -> Vec { + state + .files + .get(&file_id) + .map(|file| file.trk.iter().map(|trk| trk.id).collect()) + .unwrap_or_default() +} + +fn segment_ids_of(state: &State, file_id: FileId, trk_id: TrackId) -> Vec { + state + .files + .get(&file_id) + .and_then(|file| file.trk.iter().find(|trk| trk.id == trk_id)) + .map(|trk| trk.trkseg.iter().map(|seg| seg.id).collect()) + .unwrap_or_default() +} + +fn waypoint_ids_of(state: &State, file_id: FileId) -> Vec { + state + .files + .get(&file_id) + .map(|file| waypoint_ids(file).collect()) + .unwrap_or_default() +} + +/// Where the content goes, depending on what is selected. +fn destination(state: &State, content: &ClipboardContent) -> Result { + let nothing = CommandError::NothingToDo; + let last_file = |file_ids: &HashSet| { + last_selected(state.order.0.iter().copied(), file_ids).ok_or(CommandError::NothingToDo) + }; + Ok(match (content, &*state.selection) { + (ClipboardContent::Files(_), _) => Destination::Files(Place::End), + (ClipboardContent::Tracks(_) | ClipboardContent::Segments(_), Selection::Empty) => { + Destination::Files(Place::End) + } + ( + ClipboardContent::Tracks(_) | ClipboardContent::Segments(_), + Selection::File { file_ids }, + ) => Destination::Tracks(last_file(file_ids)?, Place::End), + (ClipboardContent::Tracks(_), Selection::Track { file_id, trk_ids }) => { + let last = + last_selected(track_ids_of(state, *file_id).into_iter(), trk_ids).ok_or(nothing)?; + Destination::Tracks(*file_id, Place::After(last)) + } + (ClipboardContent::Segments(_), Selection::Track { file_id, trk_ids }) => { + let last = + last_selected(track_ids_of(state, *file_id).into_iter(), trk_ids).ok_or(nothing)?; + Destination::Segments(*file_id, last, Place::End) + } + ( + ClipboardContent::Segments(_), Selection::TrackSegment { file_id, trk_id, trkseg_ids, - } => { - 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), - }; - *state.selection = next; - *state.clipboard = None; - Ok(()) - } + }, + ) => { + let last = last_selected( + segment_ids_of(state, *file_id, *trk_id).into_iter(), + trkseg_ids, + ) + .ok_or(nothing)?; + Destination::Segments(*file_id, *trk_id, Place::After(last)) + } + (ClipboardContent::Waypoints(_), Selection::File { file_ids }) => { + Destination::Waypoints(last_file(file_ids)?, Place::End) + } + (ClipboardContent::Waypoints(_), Selection::Waypoints { file_id }) => { + Destination::Waypoints(*file_id, Place::End) + } + (ClipboardContent::Waypoints(_), Selection::Waypoint { file_id, wpt_ids }) => { + let last = last_selected(waypoint_ids_of(state, *file_id).into_iter(), wpt_ids) + .ok_or(nothing)?; + Destination::Waypoints(*file_id, Place::After(last)) + } + _ => return Err(nothing), + }) } /// The last of the `selected` ids in the order of `all`: the element a paste goes after. @@ -129,8 +139,8 @@ mod tests { use std::rc::Rc; use crate::{ - Clipboard, File, FileId, Track, TrackId, TrackInfo, TrackSegment, TrackSegmentId, Waypoint, - WaypointId, engine::command::fixture::Fixture, new_file, + Clipboard, ClipboardIds, File, FileId, Track, TrackId, TrackInfo, TrackSegment, + TrackSegmentId, Waypoint, WaypointId, engine::command::fixture::Fixture, new_file, }; use super::*; @@ -188,7 +198,12 @@ mod tests { } fn copy(fx: &mut Fixture, selection: Selection, cut: bool) { - fx.clipboard = Clipboard::new(selection, cut); + fx.clipboard = Clipboard::new(&selection, &fx.files, &fx.order.0, cut); + } + + /// The ids of what is in the clipboard, and whether it is cut. + fn clipboard_state(fx: &Fixture) -> Option<(ClipboardIds, bool)> { + fx.clipboard.as_ref().map(|c| (c.content.ids(), c.cut)) } fn paste(fx: &mut Fixture) -> Result<(), CommandError> { @@ -254,9 +269,12 @@ mod tests { // the segments are copies too assert_eq!(fx.files[&b].trk[2].trkseg.len(), 2); assert_ne!(segment_ids(&fx, b, 2), segment_ids(&fx, a, 1)); - // the pasted tracks are selected, and the clipboard is empty + // the pasted tracks are selected, and the clipboard is still there assert_eq!(fx.selection, tracks(b, &in_b[1..])); - assert_eq!(fx.clipboard, None); + assert_eq!( + clipboard_state(&fx), + Some((ClipboardIds::Tracks(vec![source[0], source[1]]), false)) + ); } #[test] @@ -535,7 +553,7 @@ mod tests { assert!(fx.clipboard.is_some()); assert_eq!(fx.selection, waypoints(a, &w)); - // elements that do not exist anymore + // nothing to copy: what does not exist is not put in the clipboard copy(&mut fx, tracks(a, &[TrackId::default()]), false); fx.selection = files(&[a]); assert_eq!(paste(&mut fx), Err(CommandError::NothingToDo)); @@ -547,4 +565,226 @@ mod tests { assert_eq!(fx.files.len(), 1); assert_eq!(fx.order.0, vec![a]); } + + #[test] + fn test_paste_a_copy_several_times() { + let mut fx = Fixture::default(); + let a = add(&mut fx, file("a", &[(Some("t0"), 1), (Some("t1"), 1)], 1)); + let b = add(&mut fx, file("b", &[], 0)); + let t = track_ids(&fx, a); + copy(&mut fx, tracks(a, &[t[0]]), false); + fx.selection = files(&[b]); + + paste(&mut fx).unwrap(); + paste(&mut fx).unwrap(); + paste(&mut fx).unwrap(); + + // each paste made a copy with new ids, and the source is untouched + let in_b = track_ids(&fx, b); + assert_eq!(in_b.len(), 3); + assert_eq!(in_b.iter().collect::>().len(), 3); + assert!(in_b.iter().all(|id| !t.contains(id))); + assert_eq!(track_ids(&fx, a), t); + assert!(fx.clipboard.is_some()); + + // the selection is the last copy: the next paste goes after it + assert_eq!(fx.selection, tracks(b, &[in_b[2]])); + paste(&mut fx).unwrap(); + assert_eq!(track_ids(&fx, b).len(), 4); + + // the same with other elements + let w = wpt_ids(&fx, a); + copy(&mut fx, waypoints(a, &w), false); + fx.selection = files(&[b]); + paste(&mut fx).unwrap(); + paste(&mut fx).unwrap(); + assert_eq!(wpt_ids(&fx, b).len(), 2); + assert_eq!(wpt_ids(&fx, a), w); + + copy(&mut fx, files(&[b]), false); + fx.selection = Selection::Empty; + paste(&mut fx).unwrap(); + paste(&mut fx).unwrap(); + assert_eq!(fx.files.len(), 4); + } + + #[test] + fn test_paste_a_cut_moves_the_first_time_then_copies() { + let mut fx = Fixture::default(); + let a = add(&mut fx, file("a", &[(Some("t0"), 1), (Some("t1"), 1)], 0)); + let b = add(&mut fx, file("b", &[], 0)); + let c = add(&mut fx, file("c", &[], 0)); + let t = track_ids(&fx, a); + copy(&mut fx, tracks(a, &[t[0]]), true); + fx.selection = files(&[b]); + + paste(&mut fx).unwrap(); + // moved with its id, and what is in the clipboard now is a copy of it + assert_eq!(track_ids(&fx, a), vec![t[1]]); + assert_eq!(track_ids(&fx, b), vec![t[0]]); + assert_eq!( + clipboard_state(&fx), + Some((ClipboardIds::Tracks(vec![t[0]]), false)) + ); + + fx.selection = files(&[c]); + paste(&mut fx).unwrap(); + paste(&mut fx).unwrap(); + // copies: the moved track stays where it was moved to + assert_eq!(track_ids(&fx, b), vec![t[0]]); + let in_c = track_ids(&fx, c); + assert_eq!(in_c.len(), 2); + assert!(!in_c.contains(&t[0])); + assert_eq!(track_ids(&fx, a), vec![t[1]]); + } + + #[test] + fn test_paste_cut_files_several_times() { + let mut fx = Fixture::default(); + let a = add(&mut fx, file("a", &[], 0)); + let b = add(&mut fx, file("b", &[], 0)); + copy(&mut fx, files(&[a]), true); + fx.selection = Selection::Empty; + paste(&mut fx).unwrap(); + assert_eq!(fx.order.0, vec![b, a]); + // the next pastes copy it + paste(&mut fx).unwrap(); + assert_eq!(fx.files.len(), 3); + assert_eq!(fx.order.0[..2], [b, a]); + } + + /// Renames the first track of the file, as an edit would. + fn rename_track(fx: &mut Fixture, id: FileId, name: &str) { + let file = std::rc::Rc::make_mut(fx.files.get_mut(&id).unwrap()); + file.trk[0].info.name = Some(name.to_owned()); + } + + #[test] + fn test_paste_the_elements_as_they_were_when_copied() { + let mut fx = Fixture::default(); + let a = add(&mut fx, file("a", &[(Some("original"), 1)], 0)); + let b = add(&mut fx, file("b", &[], 0)); + let t = track_ids(&fx, a); + copy(&mut fx, tracks(a, &[t[0]]), false); + rename_track(&mut fx, a, "edited"); + fx.selection = files(&[b]); + + paste(&mut fx).unwrap(); + let pasted = &fx.files[&b].trk[0]; + assert_eq!(pasted.info.name.as_deref(), Some("original")); + // the source keeps what was done to it + assert_eq!(fx.files[&a].trk[0].info.name.as_deref(), Some("edited")); + + // and each time + paste(&mut fx).unwrap(); + assert!( + fx.files[&b] + .trk + .iter() + .all(|trk| trk.info.name.as_deref() == Some("original")) + ); + assert_eq!(fx.files[&b].trk.len(), 2); + } + + #[test] + fn test_paste_a_cut_as_it_was_when_cut() { + let mut fx = Fixture::default(); + let a = add( + &mut fx, + file("a", &[(Some("original"), 1), (Some("other"), 1)], 0), + ); + let b = add(&mut fx, file("b", &[], 0)); + let t = track_ids(&fx, a); + copy(&mut fx, tracks(a, &[t[0]]), true); + rename_track(&mut fx, a, "edited"); + fx.selection = files(&[b]); + + paste(&mut fx).unwrap(); + // what is in the clipboard is moved, the edited version is not what is pasted + assert_eq!(track_ids(&fx, a), vec![t[1]]); + assert_eq!(track_ids(&fx, b), vec![t[0]]); + assert_eq!(fx.files[&b].trk[0].info.name.as_deref(), Some("original")); + } + + #[test] + fn test_paste_after_the_elements_were_deleted() { + let delete_file = |fx: &mut Fixture, id: FileId| { + fx.files.remove(&id); + fx.order.0.retain(|other| *other != id); + }; + + // copied tracks, waypoints, segments and files are still there to be pasted + let mut fx = Fixture::default(); + let a = add(&mut fx, file("a", &[(Some("t"), 2)], 2)); + let b = add(&mut fx, file("b", &[], 0)); + let t = track_ids(&fx, a); + let s = segment_ids(&fx, a, 0); + let w = wpt_ids(&fx, a); + + copy(&mut fx, tracks(a, &t), false); + fx.selection = files(&[b]); + delete_file(&mut fx, a); + paste(&mut fx).unwrap(); + assert_eq!(fx.files[&b].trk.len(), 1); + assert_eq!(fx.files[&b].trk[0].trkseg.len(), 2); + // the copy of a track comes from a file that is gone: named like the track + fx.selection = Selection::Empty; + paste(&mut fx).unwrap(); + assert_eq!(names(&fx), ["b", "t"]); + + let mut fx = Fixture::default(); + let a = add(&mut fx, file("a", &[(Some("t"), 2)], 2)); + let b = add(&mut fx, file("b", &[], 0)); + let (t, s, w) = (track_ids(&fx, a), segment_ids(&fx, a, 0), wpt_ids(&fx, a)); + copy(&mut fx, segments(a, t[0], &s), false); + fx.selection = files(&[b]); + delete_file(&mut fx, a); + paste(&mut fx).unwrap(); + assert_eq!(fx.files[&b].trk.len(), 2); + + let mut fx = Fixture::default(); + let a = add(&mut fx, file("a", &[(Some("t"), 2)], 2)); + let b = add(&mut fx, file("b", &[], 0)); + let w = wpt_ids(&fx, a); + copy(&mut fx, waypoints(a, &w), false); + fx.selection = files(&[b]); + delete_file(&mut fx, a); + paste(&mut fx).unwrap(); + assert_eq!(wpt_ids(&fx, b).len(), 2); + + let mut fx = Fixture::default(); + let a = add(&mut fx, file("a", &[(Some("t"), 1)], 0)); + copy(&mut fx, files(&[a]), false); + fx.selection = Selection::Empty; + delete_file(&mut fx, a); + assert!(fx.files.is_empty()); + paste(&mut fx).unwrap(); + paste(&mut fx).unwrap(); + assert_eq!(fx.files.len(), 2); + assert_eq!(names(&fx), ["a", "a"]); + let _ = (s, w); + } + + #[test] + fn test_paste_a_cut_after_the_elements_were_deleted() { + let mut fx = Fixture::default(); + let a = add(&mut fx, file("a", &[(Some("t0"), 1), (Some("t1"), 1)], 0)); + let b = add(&mut fx, file("b", &[], 0)); + let t = track_ids(&fx, a); + copy(&mut fx, tracks(a, &[t[0]]), true); + // deleted before it is pasted + std::rc::Rc::make_mut(fx.files.get_mut(&a).unwrap()) + .trk + .retain(|trk| trk.id != t[0]); + fx.selection = files(&[b]); + + paste(&mut fx).unwrap(); + // nothing to remove, the track is where it was pasted, with its id + assert_eq!(track_ids(&fx, a), vec![t[1]]); + assert_eq!(track_ids(&fx, b), vec![t[0]]); + assert_eq!( + clipboard_state(&fx), + Some((ClipboardIds::Tracks(vec![t[0]]), false)) + ); + } } diff --git a/gpx-rs/engine/src/engine/command/file/transfer.rs b/gpx-rs/engine/src/engine/command/file/transfer.rs index e38452dac..b3664afcc 100644 --- a/gpx-rs/engine/src/engine/command/file/transfer.rs +++ b/gpx-rs/engine/src/engine/command/file/transfer.rs @@ -1,13 +1,13 @@ -//! 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. +//! Copying or moving elements to a place of the file tree: what paste and move are made of. +//! The elements come as the content of a clipboard, and a destination 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, + ClipboardContent, ClipboardSegment, ClipboardTrack, 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. @@ -35,6 +35,22 @@ impl Place { } } +/// Where elements go. What can go where: +/// - files: among the files; +/// - tracks: among the files (each track becomes a file, named like the track, or like the file +/// it comes from), or among the tracks of a file; +/// - segments: among the files (each segment becomes a file with a track, named like its track, +/// or like its file), among the tracks of a file (each segment becomes a track), or among the +/// segments of a track; +/// - waypoints: among the waypoints of a file. +#[derive(Debug, Clone, Copy)] +pub enum Destination { + Files(Place), + Tracks(FileId, Place), + Segments(FileId, TrackId, Place), + Waypoints(FileId, Place), +} + pub fn waypoint_ids(file: &File) -> impl Iterator + '_ { file.wpt .iter() @@ -60,223 +76,227 @@ pub fn new_file(name: &str, trk: Vec) -> 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 { +fn add_files(state: &mut State, files: Vec>, place: Place) -> Selection { 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.files.insert(file.id, file); } state.order.0.splice(at..at, ids.iter().copied()); - ids.into_iter().collect() + Selection::File { + file_ids: 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); +/// Removes the elements of the content from the files, wherever they are. +pub fn remove_elements(state: &mut State, content: &ClipboardContent) { + match content { + ClipboardContent::Files(files) => { + let ids: HashSet = files.iter().map(|file| file.id).collect(); + state.files.retain(|id, _| !ids.contains(id)); + state.order.0.retain(|id| !ids.contains(id)); + } + ClipboardContent::Tracks(tracks) => { + let ids: HashSet = tracks.iter().map(|trk| trk.track.id).collect(); + for file in state.files.values_mut() { + if file.trk.iter().any(|trk| ids.contains(&trk.id)) { + Rc::make_mut(file).trk.retain(|trk| !ids.contains(&trk.id)); + } + } + } + ClipboardContent::Segments(segments) => { + let ids: HashSet = segments.iter().map(|seg| seg.segment.id).collect(); + for file in state.files.values_mut() { + let contains = |trk: &Track| trk.trkseg.iter().any(|seg| ids.contains(&seg.id)); + if file.trk.iter().any(contains) { + for trk in Rc::make_mut(file).trk.iter_mut() { + trk.trkseg.retain(|seg| !ids.contains(&seg.id)); + } + } + } + } + ClipboardContent::Waypoints(waypoints) => { + let ids: HashSet = waypoints.iter().map(|wpt| wpt.id).collect(); + for file in state.files.values_mut() { + if waypoint_ids(file).any(|id| ids.contains(&id)) { + edit_waypoint_chunks( + Rc::make_mut(file), + |wpt| ids.contains(&wpt.id), + |wpts| { + wpts.retain(|wpt| !ids.contains(&wpt.id)); + true + }, + ); + } + } + } } +} - 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) +/// Puts the elements of the content at the destination: copies of them, or the elements +/// themselves, with their ids, when `cut` (they are removed from where they are first). Returns +/// the selection of what was put. +pub fn transfer( + state: &mut State, + content: &ClipboardContent, + cut: bool, + to: Destination, +) -> Result { + // the destination has to be there, before anything is changed + let exists = match to { + Destination::Files(_) => true, + Destination::Tracks(file_id, _) => state.files.contains_key(&file_id), + Destination::Segments(file_id, trk_id, _) => state + .files + .get(&file_id) + .is_some_and(|file| file.trk.iter().any(|trk| trk.id == trk_id)), + Destination::Waypoints(file_id, _) => state.files.contains_key(&file_id), }; - 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) - { + let valid = match (content, to) { + (ClipboardContent::Files(_), Destination::Files(_)) + | (ClipboardContent::Tracks(_), Destination::Files(_) | Destination::Tracks(..)) + | ( + ClipboardContent::Segments(_), + Destination::Files(_) | Destination::Tracks(..) | Destination::Segments(..), + ) + | (ClipboardContent::Waypoints(_), Destination::Waypoints(..)) => true, + _ => false, + }; + let empty = match content { + ClipboardContent::Files(files) => files.is_empty(), + ClipboardContent::Tracks(tracks) => tracks.is_empty(), + ClipboardContent::Segments(segments) => segments.is_empty(), + ClipboardContent::Waypoints(waypoints) => waypoints.is_empty(), + }; + if !exists || !valid || empty { return Err(CommandError::NothingToDo); } if cut { - file_mut(state, file_id)? - .trk - .retain(|trk| !source.contains(&trk.id)); + remove_elements(state, content); } - let tracks: Vec = if cut { - tracks - } else { - tracks.iter().map(copy_track).collect() - }; - let ids: HashSet = tracks.iter().map(|trk| trk.id).collect(); + let copy = !cut; + match (content, to) { + (ClipboardContent::Files(files), Destination::Files(place)) => { + let files = files + .iter() + .map(|file| { + if copy { + Rc::new(copy_file(file)) + } else { + file.clone() + } + }) + .collect(); + Ok(add_files(state, files, place)) + } + (ClipboardContent::Tracks(tracks), to) => transfer_tracks(state, tracks, copy, to), + (ClipboardContent::Segments(segments), to) => transfer_segments(state, segments, copy, to), + (ClipboardContent::Waypoints(waypoints), Destination::Waypoints(file_id, place)) => { + transfer_waypoints(state, waypoints, copy, file_id, place) + } + _ => Err(CommandError::NothingToDo), + } +} - Ok(match to { - TracksTo::Files(place) => { +fn transfer_tracks( + state: &mut State, + tracks: &[ClipboardTrack], + copy: bool, + to: Destination, +) -> Result { + let tracks: Vec<(Track, &str)> = tracks + .iter() + .map(|clip| { + let track = if copy { + copy_track(&clip.track) + } else { + clip.track.clone() + }; + (track, clip.file_name.as_str()) + }) + .collect(); + match to { + Destination::Files(place) => { let files = tracks .into_iter() - .map(|track| { + .map(|(track, file_name)| { let name = track .info .name .clone() - .unwrap_or_else(|| source_name.clone()); - new_file(&name, vec![track]) + .unwrap_or_else(|| file_name.to_owned()); + Rc::new(new_file(&name, vec![track])) }) .collect(); - Selection::File { - file_ids: add_files(state, files, place), - } + Ok(add_files(state, files, place)) } - TracksTo::File(id, place) => { - let file = file_mut(state, id)?; + Destination::Tracks(file_id, place) => { + let tracks: Vec = tracks.into_iter().map(|(track, _)| track).collect(); + let ids: HashSet = tracks.iter().map(|trk| trk.id).collect(); + let file = file_mut(state, file_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, + Ok(Selection::Track { + file_id, trk_ids: ids, - } + }) } - }) + _ => Err(CommandError::NothingToDo), + } } -/// 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( +fn transfer_segments( state: &mut State, - file_id: FileId, - trk_id: TrackId, - source: &HashSet, - cut: bool, - to: SegmentsTo, + segments: &[ClipboardSegment], + copy: bool, + to: Destination, ) -> Result { - let source_file = state.files.get(&file_id).ok_or(CommandError::NothingToDo)?; - let source_track = source_file - .trk + let segments: Vec<(TrackSegment, &str)> = segments .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() + .map(|clip| { + let segment = if copy { + copy_segment(&clip.segment) + } else { + clip.segment.clone() + }; + (segment, clip.name.as_str()) + }) .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) => { + match to { + Destination::Files(place) => { let files = segments .into_iter() - .map(|segment| new_file(&source_name, vec![own_track(segment)])) + .map(|(segment, name)| Rc::new(new_file(name, vec![own_track(segment)]))) .collect(); - Selection::File { - file_ids: add_files(state, files, place), - } + Ok(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)?; + Destination::Tracks(file_id, place) => { + let tracks: Vec = segments + .into_iter() + .map(|(segment, _)| own_track(segment)) + .collect(); + let ids = tracks.iter().map(|trk| trk.id).collect(); + let file = file_mut(state, file_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, - } + Ok(Selection::Track { + file_id, + trk_ids: ids, + }) } - SegmentsTo::Track(id, trk_id, place) => { - let trk = file_mut(state, id)? + Destination::Segments(file_id, trk_id, place) => { + let segments: Vec = + segments.into_iter().map(|(segment, _)| segment).collect(); + let ids: HashSet = segments.iter().map(|seg| seg.id).collect(); + let trk = file_mut(state, file_id)? .trk .iter_mut() .find(|trk| trk.id == trk_id) @@ -284,55 +304,39 @@ pub fn transfer_segments( 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, + Ok(Selection::TrackSegment { + file_id, trk_id, trkseg_ids: ids, - } + }) } - }) + Destination::Waypoints(..) => Err(CommandError::NothingToDo), + } } -/// 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( +fn transfer_waypoints( state: &mut State, + waypoints: &[Waypoint], + copy: bool, 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 + let waypoints: Vec = waypoints .iter() - .flat_map(|chunk| &chunk.wpt) - .filter(|wpt| selected(wpt)) - .cloned() + .map(|wpt| { + if copy { + copy_waypoint(wpt) + } else { + wpt.clone() + } + }) .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 file = file_mut(state, file_id)?; let existing: Vec = waypoint_ids(file).collect(); insert_waypoints_at(file, place.position(&existing), waypoints); Ok(Selection::Waypoint { - file_id: to, + file_id, wpt_ids: ids, }) } diff --git a/gpx-rs/engine/src/engine/engine.rs b/gpx-rs/engine/src/engine/engine.rs index 65a1f9944..1ec33777c 100644 --- a/gpx-rs/engine/src/engine/engine.rs +++ b/gpx-rs/engine/src/engine/engine.rs @@ -177,7 +177,9 @@ impl Engine { /// Puts the selection in the clipboard, if there is one. fn copy(&mut self, cut: bool) { - if let Some(clipboard) = Clipboard::new(self.selection.clone(), cut) { + if let Some(files) = self.stack.current() + && let Some(clipboard) = Clipboard::new(&self.selection, files, &self.order.0, cut) + { self.clipboard = Some(clipboard); } } @@ -202,18 +204,10 @@ impl Engine { // files coming back after an undo/redo are added at the end of the order self.order.sync(files); self.selection.retain_existing(files); - // what was copied is not pasted if it does not exist anymore - if let Some(clipboard) = &mut self.clipboard { - clipboard.selection.retain_existing(files); - if clipboard.selection == Selection::Empty { - self.clipboard = None; - } - } } None => { self.order.0.clear(); self.selection = Selection::Empty; - self.clipboard = None; } } self.statistics_cache.update(current); @@ -233,7 +227,8 @@ mod tests { use std::collections::HashSet; use crate::{ - Delete, DeleteAll, Load, Metadata, New, NewTrack, Paste, Style, TrackId, TrackSegmentId, + ClipboardIds, Delete, DeleteAll, Load, Metadata, New, NewTrack, Paste, Style, TrackId, + TrackSegmentId, }; use super::*; @@ -902,8 +897,8 @@ mod tests { assert!(engine.execute(Action::Copy)); assert!(engine.clipboard_changed() && !engine.selection_changed()); assert_eq!( - engine.clipboard().map(|c| (c.cut, &c.selection)), - Some((false, &selected_tracks)) + engine.clipboard().map(|c| (c.content.ids(), c.cut)), + Some((ClipboardIds::Tracks(tracks.clone()), false)) ); // tracks cannot be pasted onto themselves... but into a track, a file or the list of files assert!(engine.can_paste()); @@ -921,15 +916,20 @@ mod tests { assert!(paste(&mut engine)); assert_eq!(track_ids(&engine, a), tracks); assert_eq!(track_ids(&engine, b).len(), 2); - assert!(engine.clipboard().is_none() && engine.clipboard_changed()); + // a copy stays in the clipboard, to be pasted again + assert!(engine.clipboard().is_some() && !engine.clipboard_changed()); assert!( matches!(engine.selection(), Selection::Track { file_id, trk_ids } if *file_id == b && trk_ids.len() == 2) ); - // the clipboard is empty: nothing more to paste - assert!(!paste(&mut engine)); + assert!(engine.can_paste()); + assert!(paste(&mut engine)); + assert_eq!(track_ids(&engine, b).len(), 4); + assert_eq!(track_ids(&engine, a), tracks); // and it can be undone assert!(engine.execute(Action::Undo)); + assert_eq!(track_ids(&engine, b).len(), 2); + assert!(engine.execute(Action::Undo)); assert!(track_ids(&engine, b).is_empty()); } @@ -962,7 +962,7 @@ mod tests { } #[test] - fn test_clipboard_drops_what_does_not_exist_anymore() { + fn test_clipboard_survives_what_happens_to_the_elements() { let mut engine = Engine::default(); let a = file_with_tracks(&mut engine, "a", 2); let tracks = track_ids(&engine, a); @@ -975,8 +975,10 @@ mod tests { SelectMode::Replace, ); engine.execute(Action::Copy); + let clipboard = engine.clipboard().cloned(); + assert!(clipboard.is_some()); - // one of the tracks is deleted: the other one is still there to paste + // one of the tracks is deleted, then the whole file: the clipboard is not affected select_elements( &mut engine, Selection::Track { @@ -989,15 +991,23 @@ mod tests { &mut engine, Command::Delete(Delete { whole_files: false }) )); - assert!( - matches!(engine.clipboard().map(|c| &c.selection), Some(Selection::Track { trk_ids, .. }) if trk_ids.len() == 1) - ); - - // the whole file is deleted: nothing left select_elements(&mut engine, files(&[a]), SelectMode::Replace); assert!(edit(&mut engine, Command::DeleteAll(DeleteAll))); - assert!(engine.clipboard().is_none()); - assert!(engine.clipboard_changed()); + assert_eq!(engine.clipboard(), clipboard.as_ref()); + assert!(!engine.clipboard_changed()); + + // both tracks are pasted in a new file, as they were when they were copied + let b = file_with_tracks(&mut engine, "b", 0); + select_files(&mut engine, &[b]); + assert!(engine.can_paste()); + assert!(paste(&mut engine)); + let pasted = track_ids(&engine, b); + assert_eq!(pasted.len(), 2); + // copies, with new ids + assert!(pasted.iter().all(|id| !tracks.contains(id))); + // and again + assert!(paste(&mut engine)); + assert_eq!(track_ids(&engine, b).len(), 4); } #[test] @@ -1010,12 +1020,12 @@ mod tests { select_files(&mut engine, &[b]); assert!(engine.execute(Action::Cut)); assert_eq!( - engine.clipboard().map(|c| (c.cut, &c.selection)), - Some((true, &files(&[b]))) + engine.clipboard().map(|c| (c.content.ids(), c.cut)), + Some((ClipboardIds::Files(vec![b]), true)) ); - // the same content again: nothing changed - assert!(!engine.execute(Action::Cut)); - assert!(!engine.clipboard_changed()); + // the same elements again are a new copy + assert!(engine.execute(Action::Cut)); + assert!(engine.clipboard_changed()); // only the creations of the files are in the history assert!(engine.execute(Action::Undo)); assert!(engine.execute(Action::Undo)); diff --git a/gpx-rs/engine/src/engine/state/clipboard.rs b/gpx-rs/engine/src/engine/state/clipboard.rs index 391ba6a97..da3bc01f9 100644 --- a/gpx-rs/engine/src/engine/state/clipboard.rs +++ b/gpx-rs/engine/src/engine/state/clipboard.rs @@ -1,17 +1,159 @@ -use crate::Selection; +use std::rc::Rc; -/// What was copied or cut: the elements, by id, so that they are found again when pasting, even -/// if the files changed in between. Cut elements stay in place until they are pasted. -#[derive(Debug, Clone, PartialEq, Eq)] +use uuid::Uuid; + +use crate::{ + File, FileId, Selection, StackEntry, Track, TrackId, TrackSegment, TrackSegmentId, Waypoint, + WaypointId, +}; + +/// A track in the clipboard, with the name of the file it comes from (the name of the new file +/// when it is pasted on its own). +#[derive(Debug, Clone)] +pub struct ClipboardTrack { + pub track: Track, + pub file_name: String, +} + +/// A segment in the clipboard, with the name of its track, or of its file when the track has +/// none (the name of the new file when it is pasted on its own). +#[derive(Debug, Clone)] +pub struct ClipboardSegment { + pub segment: TrackSegment, + pub name: String, +} + +/// The elements that were copied or cut, in the order of the files. +/// +/// It holds the elements themselves, not references to them: they are cheap to keep (files are +/// shared, and so are the trackpoints of tracks and segments), and the clipboard stays valid +/// when the elements are changed or deleted. +#[derive(Debug, Clone)] +pub enum ClipboardContent { + Files(Vec>), + Tracks(Vec), + Segments(Vec), + Waypoints(Vec), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ClipboardId(Uuid); + +impl Default for ClipboardId { + fn default() -> Self { + Self(Uuid::new_v4()) + } +} + +/// What was copied or cut, to be pasted, as many times as needed. +/// +/// What is pasted is always the elements as they were when they were copied or cut, whatever +/// happened to them since, even if they were deleted. Pasting what was copied makes copies, with +/// new ids. What was cut is moved, with its ids, the first time it is pasted (the elements that +/// are still there are removed): the clipboard is not cut anymore after that, and the next +/// pastes make copies. +#[derive(Debug, Clone)] pub struct Clipboard { - pub selection: Selection, + id: ClipboardId, + pub content: ClipboardContent, pub cut: bool, } +/// Two clipboards are the same when they come from the same copy, and are cut the same way. +impl PartialEq for Clipboard { + fn eq(&self, other: &Self) -> bool { + self.id == other.id && self.cut == other.cut + } +} + impl Clipboard { - /// `None` if nothing is selected. - pub fn new(selection: Selection, cut: bool) -> Option { - (selection != Selection::Empty).then_some(Clipboard { selection, cut }) + /// The clipboard for the selected elements, `None` if nothing is selected (or if what is + /// selected does not exist). + pub fn new( + selection: &Selection, + files: &StackEntry, + order: &[FileId], + cut: bool, + ) -> Option { + let content = match selection { + Selection::Empty => return None, + Selection::File { file_ids } => ClipboardContent::Files( + order + .iter() + .filter(|id| file_ids.contains(id)) + .filter_map(|id| files.get(id).cloned()) + .collect(), + ), + Selection::Track { file_id, trk_ids } => { + let file = files.get(file_id)?; + ClipboardContent::Tracks( + file.trk + .iter() + .filter(|trk| trk_ids.contains(&trk.id)) + .map(|trk| ClipboardTrack { + track: trk.clone(), + file_name: file.info.name.clone(), + }) + .collect(), + ) + } + Selection::TrackSegment { + file_id, + trk_id, + trkseg_ids, + } => { + let file = files.get(file_id)?; + let trk = file.trk.iter().find(|trk| trk.id == *trk_id)?; + let name = trk + .info + .name + .clone() + .unwrap_or_else(|| file.info.name.clone()); + ClipboardContent::Segments( + trk.trkseg + .iter() + .filter(|seg| trkseg_ids.contains(&seg.id)) + .map(|seg| ClipboardSegment { + segment: seg.clone(), + name: name.clone(), + }) + .collect(), + ) + } + Selection::Waypoints { file_id } => ClipboardContent::Waypoints( + files + .get(file_id)? + .wpt + .iter() + .flat_map(|chunk| chunk.wpt.iter().cloned()) + .collect(), + ), + Selection::Waypoint { file_id, wpt_ids } => ClipboardContent::Waypoints( + files + .get(file_id)? + .wpt + .iter() + .flat_map(|chunk| &chunk.wpt) + .filter(|wpt| wpt_ids.contains(&wpt.id)) + .cloned() + .collect(), + ), + }; + let clipboard = Clipboard { + id: ClipboardId::default(), + content, + cut, + }; + (!clipboard.is_empty()).then_some(clipboard) + } + + pub fn is_empty(&self) -> bool { + match &self.content { + ClipboardContent::Files(files) => files.is_empty(), + ClipboardContent::Tracks(tracks) => tracks.is_empty(), + ClipboardContent::Segments(segments) => segments.is_empty(), + ClipboardContent::Waypoints(waypoints) => waypoints.is_empty(), + } } /// Whether the content can be pasted when `target` is selected: files and tracks can be @@ -25,91 +167,306 @@ impl Clipboard { /// | segments | nothing, a file, a track, a segment | /// | waypoints | a file, the waypoints, a waypoint | pub fn can_paste(&self, target: &Selection) -> bool { + use ClipboardContent as C; use Selection::*; matches!( - (&self.selection, target), - (File { .. }, Empty | File { .. }) - | (Track { .. }, Empty | File { .. } | Track { .. }) + (&self.content, target), + (C::Files(_), Empty | File { .. }) + | (C::Tracks(_), Empty | File { .. } | Track { .. }) | ( - TrackSegment { .. }, + C::Segments(_), Empty | File { .. } | Track { .. } | TrackSegment { .. } ) | ( - Waypoints { .. } | Waypoint { .. }, + C::Waypoints(_), File { .. } | Waypoints { .. } | Waypoint { .. } ) ) } } +/// The ids of the elements of the content, by kind. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClipboardIds { + Files(Vec), + Tracks(Vec), + Segments(Vec), + Waypoints(Vec), +} + +impl ClipboardContent { + pub fn ids(&self) -> ClipboardIds { + match self { + ClipboardContent::Files(files) => { + ClipboardIds::Files(files.iter().map(|file| file.id).collect()) + } + ClipboardContent::Tracks(tracks) => { + ClipboardIds::Tracks(tracks.iter().map(|trk| trk.track.id).collect()) + } + ClipboardContent::Segments(segments) => { + ClipboardIds::Segments(segments.iter().map(|seg| seg.segment.id).collect()) + } + ClipboardContent::Waypoints(waypoints) => { + ClipboardIds::Waypoints(waypoints.iter().map(|wpt| wpt.id).collect()) + } + } + } +} + #[cfg(test)] mod tests { - use std::collections::HashSet; - - use crate::{FileId, TrackId, TrackSegmentId, WaypointId}; + use crate::{TrackInfo, WaypointChunk}; use super::*; - fn file() -> Selection { - Selection::File { - file_ids: [FileId::default()].into(), + /// A file with 3 tracks (named "t0", "t1", "t2") of 2 segments and 3 waypoints. + fn file(name: &str) -> File { + let mut file = File::default(); + file.info.name = name.to_owned(); + for i in 0..3 { + file.trk.push(Track { + info: TrackInfo { + name: (i < 2).then(|| format!("t{i}")), + ..Default::default() + }, + trkseg: vec![TrackSegment::default(), TrackSegment::default()], + ..Default::default() + }); } + file.wpt.push(Rc::new(WaypointChunk { + wpt: vec![Waypoint::default(), Waypoint::default()], + ..Default::default() + })); + file.wpt.push(Rc::new(WaypointChunk { + wpt: vec![Waypoint::default()], + ..Default::default() + })); + file } - fn track() -> Selection { + fn files_of(files: Vec) -> StackEntry { + files + .into_iter() + .map(|file| (file.id, Rc::new(file))) + .collect() + } + + fn order_of(files: &StackEntry) -> Vec { + files.keys().copied().collect() + } + + fn tracks(file: &File, ids: &[usize]) -> Selection { Selection::Track { - file_id: FileId::default(), - trk_ids: [TrackId::default()].into(), + file_id: file.id, + trk_ids: ids.iter().map(|i| file.trk[*i].id).collect(), } } - fn segment() -> Selection { - Selection::TrackSegment { - file_id: FileId::default(), - trk_id: TrackId::default(), - trkseg_ids: [TrackSegmentId::default()].into(), - } - } + #[test] + fn test_the_clipboard_holds_what_is_selected() { + let file = file("source"); + let (t0, t2) = (file.trk[0].id, file.trk[2].id); + let (s0, s1) = (file.trk[1].trkseg[0].id, file.trk[1].trkseg[1].id); + let wpt: Vec<_> = file + .wpt + .iter() + .flat_map(|c| c.wpt.iter().map(|w| w.id)) + .collect(); + let files = files_of(vec![file.clone()]); - fn waypoints() -> Selection { - Selection::Waypoints { - file_id: FileId::default(), + // tracks, in the order of the file, with the name of their file + let clipboard = + Clipboard::new(&tracks(&file, &[2, 0]), &files, &order_of(&files), false).unwrap(); + assert!(!clipboard.cut); + match &clipboard.content { + ClipboardContent::Tracks(copied) => { + assert_eq!( + copied.iter().map(|c| c.track.id).collect::>(), + vec![t0, t2] + ); + assert!(copied.iter().all(|c| c.file_name == "source")); + } + other => panic!("{other:?}"), } - } - fn waypoint() -> Selection { - Selection::Waypoint { - file_id: FileId::default(), - wpt_ids: HashSet::from([WaypointId::default()]), + // segments, with the name of their track, or else of their file + let segments = |trk: usize, ids: &[TrackSegmentId]| Selection::TrackSegment { + file_id: file.id, + trk_id: file.trk[trk].id, + trkseg_ids: ids.iter().copied().collect(), + }; + match Clipboard::new(&segments(1, &[s1, s0]), &files, &order_of(&files), true) + .unwrap() + .content + { + ClipboardContent::Segments(copied) => { + assert_eq!( + copied.iter().map(|c| c.segment.id).collect::>(), + vec![s0, s1] + ); + assert!(copied.iter().all(|c| c.name == "t1")); + } + other => panic!("{other:?}"), } + let unnamed = file.trk[2].trkseg[0].id; + match Clipboard::new(&segments(2, &[unnamed]), &files, &order_of(&files), true) + .unwrap() + .content + { + ClipboardContent::Segments(copied) => assert_eq!(copied[0].name, "source"), + other => panic!("{other:?}"), + } + + // waypoints: some of them, or all of them + let some = Selection::Waypoint { + file_id: file.id, + wpt_ids: [wpt[2], wpt[0]].into(), + }; + assert_eq!( + Clipboard::new(&some, &files, &order_of(&files), false) + .unwrap() + .content + .ids(), + ClipboardIds::Waypoints(vec![wpt[0], wpt[2]]) + ); + assert_eq!( + Clipboard::new( + &Selection::Waypoints { file_id: file.id }, + &files, + &order_of(&files), + false + ) + .unwrap() + .content + .ids(), + ClipboardIds::Waypoints(wpt) + ); + + // files + let selection = Selection::File { + file_ids: [file.id].into(), + }; + assert_eq!( + Clipboard::new(&selection, &files, &order_of(&files), false) + .unwrap() + .content + .ids(), + ClipboardIds::Files(vec![file.id]) + ); } #[test] fn test_nothing_to_copy() { - assert_eq!(Clipboard::new(Selection::Empty, false), None); - assert!(Clipboard::new(file(), true).is_some_and(|c| c.cut)); + let file = file("f"); + let files = files_of(vec![file.clone()]); + assert!(Clipboard::new(&Selection::Empty, &files, &order_of(&files), false).is_none()); + // elements that do not exist + let unknown = Selection::Track { + file_id: FileId::default(), + trk_ids: [TrackId::default()].into(), + }; + assert!(Clipboard::new(&unknown, &files, &order_of(&files), false).is_none()); + let unknown = Selection::Track { + file_id: file.id, + trk_ids: [TrackId::default()].into(), + }; + assert!(Clipboard::new(&unknown, &files, &order_of(&files), false).is_none()); + assert!( + Clipboard::new( + &Selection::File { + file_ids: [FileId::default()].into() + }, + &files, + &order_of(&files), + false + ) + .is_none() + ); + // a file without waypoints + let empty = files_of(vec![File::default()]); + let id = *empty.keys().next().unwrap(); + assert!( + Clipboard::new( + &Selection::Waypoints { file_id: id }, + &empty, + &order_of(&empty), + true + ) + .is_none() + ); + } + + #[test] + fn test_two_clipboards_are_the_same_when_they_come_from_the_same_copy() { + let file = file("f"); + let files = files_of(vec![file.clone()]); + let selection = tracks(&file, &[0]); + let clipboard = Clipboard::new(&selection, &files, &order_of(&files), false).unwrap(); + assert_eq!(clipboard, clipboard.clone()); + // the same elements copied again make another clipboard + assert_ne!( + clipboard, + Clipboard::new(&selection, &files, &order_of(&files), false).unwrap() + ); + // and it is not the same once it is not cut + let mut copy = clipboard.clone(); + copy.cut = true; + assert_ne!(clipboard, copy); + } + + #[test] + fn test_the_clipboard_survives_what_happens_to_the_files() { + let file = file("f"); + let mut files = files_of(vec![file.clone()]); + let clipboard = + Clipboard::new(&tracks(&file, &[0, 1]), &files, &order_of(&files), true).unwrap(); + files.clear(); + assert!(!clipboard.is_empty()); + assert_eq!( + clipboard.content.ids(), + ClipboardIds::Tracks(vec![file.trk[0].id, file.trk[1].id]) + ); } #[test] fn test_where_things_can_be_pasted() { + let file = file("f"); + let files = files_of(vec![file.clone()]); + let selection_of = |selection: Selection| { + Clipboard::new(&selection, &files, &order_of(&files), false).unwrap() + }; + let file_selection = Selection::File { + file_ids: [file.id].into(), + }; + let track = tracks(&file, &[0]); + let segment = Selection::TrackSegment { + file_id: file.id, + trk_id: file.trk[0].id, + trkseg_ids: [file.trk[0].trkseg[0].id].into(), + }; + let waypoints = Selection::Waypoints { file_id: file.id }; + let waypoint = Selection::Waypoint { + file_id: file.id, + wpt_ids: [file.wpt[0].wpt[0].id].into(), + }; let targets = [ Selection::Empty, - file(), - track(), - segment(), - waypoints(), - waypoint(), + file_selection.clone(), + track.clone(), + segment.clone(), + waypoints.clone(), + waypoint.clone(), ]; let expected = [ // nothing, file, track, segment, waypoints, waypoint - (file(), [true, true, false, false, false, false]), - (track(), [true, true, true, false, false, false]), - (segment(), [true, true, true, true, false, false]), - (waypoints(), [false, true, false, false, true, true]), - (waypoint(), [false, true, false, false, true, true]), + (file_selection, [true, true, false, false, false, false]), + (track, [true, true, true, false, false, false]), + (segment, [true, true, true, true, false, false]), + (waypoints, [false, true, false, false, true, true]), + (waypoint, [false, true, false, false, true, true]), ]; for (copied, allowed) in expected { - let clipboard = Clipboard::new(copied.clone(), false).unwrap(); + let clipboard = selection_of(copied.clone()); for (target, allowed) in targets.iter().zip(allowed) { assert_eq!( clipboard.can_paste(target), diff --git a/gpx-rs/wasm/src/lib.rs b/gpx-rs/wasm/src/lib.rs index f6c0379cf..f3de26bd0 100644 --- a/gpx-rs/wasm/src/lib.rs +++ b/gpx-rs/wasm/src/lib.rs @@ -372,7 +372,10 @@ pub fn cut() -> bool { execute(Action::Cut) } -/// Pastes the clipboard according to the selection (see `can_paste`). +/// Pastes the clipboard according to the selection (see `can_paste`). What is pasted is always +/// the elements as they were when they were copied or cut, even if they were changed or deleted +/// since. It can be pasted several times: what was copied is copied again, what was cut is moved +/// the first time (and the clipboard is not cut after that), then it is copied. #[wasm_bindgen] pub fn paste() -> bool { edit(Command::Paste(engine::Paste)) @@ -610,9 +613,10 @@ export type MoveTarget = | { 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. */ +/** What was copied or cut, to be pasted: the kind of the elements and their ids. */ export interface Clipboard { - selection: Selection; + type: 'files' | 'tracks' | 'segments' | 'waypoints'; + ids: string[]; cut: boolean; } export interface FilesUpdate { @@ -921,13 +925,21 @@ pub fn selection() -> Selection { .unchecked_into() } -/// What was copied or cut and is waiting to be pasted, `undefined` if nothing. +/// What was copied or cut and is waiting to be pasted, `undefined` if nothing: the kind of the +/// elements and their ids (UUID strings). #[wasm_bindgen] pub fn clipboard() -> Clipboard { with_engine(|e| { e.clipboard().map_or(JsValue::UNDEFINED, |clipboard| { let object = Object::new(); - set(&object, "selection", selection_object(&clipboard.selection)); + let (kind, ids) = match clipboard.content.ids() { + engine::ClipboardIds::Files(ids) => ("files", uuids(ids, |id| id.0)), + engine::ClipboardIds::Tracks(ids) => ("tracks", uuids(ids, |id| id.0)), + engine::ClipboardIds::Segments(ids) => ("segments", uuids(ids, |id| id.0)), + engine::ClipboardIds::Waypoints(ids) => ("waypoints", uuids(ids, |id| id.0)), + }; + set(&object, "type", kind); + set(&object, "ids", ids); set(&object, "cut", clipboard.cut); object.into() }) diff --git a/website/src/lib/components/file-list/FileListNodeLabel.svelte b/website/src/lib/components/file-list/FileListNodeLabel.svelte index 85d265446..614ec5fe5 100644 --- a/website/src/lib/components/file-list/FileListNodeLabel.svelte +++ b/website/src/lib/components/file-list/FileListNodeLabel.svelte @@ -32,6 +32,7 @@ import { isHidden } from '$lib/file-visibility'; import { elementId, + isInClipboard, isSelected, selectedElementIds, selectionSize, @@ -105,7 +106,7 @@ let hidden = $derived(isHidden(fileState, elementId(node))); // cut elements are greyed until they are pasted - let isCut = $derived($clipboard?.cut === true && isSelected($clipboard.selection, node)); + let isCut = $derived($clipboard?.cut === true && isInClipboard($clipboard, node)); diff --git a/website/src/lib/engine.ts b/website/src/lib/engine.ts index d6697e8b5..737572769 100644 --- a/website/src/lib/engine.ts +++ b/website/src/lib/engine.ts @@ -362,7 +362,11 @@ class Engine { * Pastes the clipboard, depending on the selection: the files, tracks and segments become new * files when nothing is selected, tracks and waypoints are added to a selected file, * segments to a selected track, and pasted after a selected track, segment or waypoint of the - * same kind. The pasted elements are selected and the clipboard is emptied. + * same kind. The pasted elements are selected. + * + * What is pasted is always the elements as they were when they were copied or cut, even if + * they were changed or deleted since. It can be pasted several times: what was copied is + * copied again, what was cut is moved the first time and copied after that. */ paste() { return this.run((w) => w.paste()); diff --git a/website/src/lib/selection-helpers.test.ts b/website/src/lib/selection-helpers.test.ts index ee29ada01..afcc9dc1d 100644 --- a/website/src/lib/selection-helpers.test.ts +++ b/website/src/lib/selection-helpers.test.ts @@ -1,11 +1,12 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import type { FileStructure, Selection } from 'gpx-rs'; +import type { Clipboard, FileStructure, Selection } from 'gpx-rs'; import type { VisibilityState } from './file-visibility'; import { elementId, hasSelectionWithin, isCovered, + isInClipboard, isEmpty, isSelected, isSelectionHidden, @@ -75,6 +76,26 @@ describe('selection helpers', () => { ); }); + it('tells which nodes are in the clipboard', () => { + const clipboard = (type: Clipboard['type'], ids: string[]): Clipboard => ({ + type, + ids, + cut: true, + }); + assert.deepEqual( + matching(empty, (_, node) => isInClipboard(undefined, node)), + [] + ); + const inClipboard = (c: Clipboard) => + Object.keys(nodes).filter((name) => isInClipboard(c, nodes[name])); + assert.deepEqual(inClipboard(clipboard('files', ['F2'])), ['F2']); + assert.deepEqual(inClipboard(clipboard('tracks', ['T2', 'S1'])), ['T2']); + assert.deepEqual(inClipboard(clipboard('segments', ['S1', 'S3'])), ['S1', 'S3']); + assert.deepEqual(inClipboard(clipboard('waypoints', ['W2'])), ['W2']); + // not mixed up with the other kinds of elements + assert.deepEqual(inClipboard(clipboard('files', ['T1', 'S1', 'W1'])), []); + }); + it('counts the selected elements', () => { assert.equal(selectionSize(empty), 0); assert.equal(selectionSize(files('F1', 'F2')), 2); diff --git a/website/src/lib/selection-helpers.ts b/website/src/lib/selection-helpers.ts index d50d88b2b..7b483dfbe 100644 --- a/website/src/lib/selection-helpers.ts +++ b/website/src/lib/selection-helpers.ts @@ -1,4 +1,4 @@ -import type { Selection } from 'gpx-rs'; +import type { Clipboard, Selection } from 'gpx-rs'; import { isHidden, waypointsKey, type VisibilityState } from '$lib/file-visibility'; /** @@ -51,6 +51,25 @@ export function selectedFileIds(selection: Selection): string[] { } } +/** Whether the node is one of the elements in the clipboard. */ +export function isInClipboard(clipboard: Clipboard | undefined, node: FileTreeNode): boolean { + if (!clipboard) { + return false; + } + switch (node.type) { + case 'file': + return clipboard.type === 'files' && clipboard.ids.includes(node.fileId); + case 'track': + return clipboard.type === 'tracks' && clipboard.ids.includes(node.trackId); + case 'segment': + return clipboard.type === 'segments' && clipboard.ids.includes(node.segmentId); + case 'waypoint': + return clipboard.type === 'waypoints' && clipboard.ids.includes(node.waypointId); + case 'waypoints': + return false; + } +} + /** Number of selected elements (the waypoints node counts for one). */ export function selectionSize(selection: Selection): number { switch (selection.type) {