From 9ff9b5475a28418e1d950ee6055431bd0dba44c1 Mon Sep 17 00:00:00 2001 From: vcoppe Date: Sun, 4 Oct 2026 15:03:37 +0200 Subject: [PATCH] progress --- gpx-rs/engine/src/engine/action.rs | 5 + gpx-rs/engine/src/engine/command/command.rs | 6 +- .../src/engine/command/file/duplicate.rs | 82 +- gpx-rs/engine/src/engine/command/file/mod.rs | 2 + .../engine/src/engine/command/file/paste.rs | 852 ++++++++++++++++++ gpx-rs/engine/src/engine/command/fixture.rs | 4 +- .../engine/src/engine/command/pattern/copy.rs | 35 + .../command/pattern/insert_waypoints.rs | 158 ++++ .../engine/src/engine/command/pattern/mod.rs | 4 + gpx-rs/engine/src/engine/engine.rs | 218 ++++- gpx-rs/engine/src/engine/state/clipboard.rs | 122 +++ gpx-rs/engine/src/engine/state/mod.rs | 3 + gpx-rs/wasm/src/lib.rs | 68 +- website/src/lib/components/Menu.svelte | 48 +- .../lib/components/file-list/FileList.svelte | 15 +- .../file-list/FileListNodeLabel.svelte | 24 +- .../file-list/sortable-file-list.ts | 9 - website/src/lib/engine.ts | 35 +- 18 files changed, 1531 insertions(+), 159 deletions(-) create mode 100644 gpx-rs/engine/src/engine/command/file/paste.rs create mode 100644 gpx-rs/engine/src/engine/command/pattern/copy.rs create mode 100644 gpx-rs/engine/src/engine/command/pattern/insert_waypoints.rs create mode 100644 gpx-rs/engine/src/engine/state/clipboard.rs diff --git a/gpx-rs/engine/src/engine/action.rs b/gpx-rs/engine/src/engine/action.rs index ea0220ef7..5e644fb95 100644 --- a/gpx-rs/engine/src/engine/action.rs +++ b/gpx-rs/engine/src/engine/action.rs @@ -26,6 +26,11 @@ pub enum Action<'a> { down: bool, add: bool, }, + /// Puts the selected elements in the clipboard, to be pasted by [`crate::Paste`]. Does + /// nothing when nothing is selected. + Copy, + /// Like `Copy`, but the elements are moved instead of copied when they are pasted. + Cut, /// Moves the files to `index` among the other files, in the given order. Not undoable. Reorder { file_ids: Vec, diff --git a/gpx-rs/engine/src/engine/command/command.rs b/gpx-rs/engine/src/engine/command/command.rs index 68037faec..3b5314b9e 100644 --- a/gpx-rs/engine/src/engine/command/command.rs +++ b/gpx-rs/engine/src/engine/command/command.rs @@ -1,8 +1,8 @@ use crate::{Apply, CommandError, State}; use crate::{ Clean, Crop, Delete, DeleteAll, Duplicate, Elevation, Extract, Load, Merge, Metadata, - MoveWaypoint, New, NewTrack, NewTrackSegment, NewWaypoint, Reverse, SpliceTrackpoints, Split, - Style, Time, + MoveWaypoint, New, NewTrack, NewTrackSegment, NewWaypoint, Paste, Reverse, SpliceTrackpoints, + Split, Style, Time, }; /// A user action that edits the files. Each variant wraps the command's own struct, whose @@ -16,6 +16,7 @@ pub enum Command<'a> { Load(Load<'a>), Duplicate(Duplicate), Delete(Delete), + Paste(Paste), DeleteAll(DeleteAll), Metadata(Metadata<'a>), Style(Style<'a>), @@ -41,6 +42,7 @@ impl Apply for Command<'_> { Command::Load(c) => c.apply(state), Command::Duplicate(c) => c.apply(state), Command::Delete(c) => c.apply(state), + Command::Paste(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/duplicate.rs b/gpx-rs/engine/src/engine/command/file/duplicate.rs index 4be58e267..aa4183de1 100644 --- a/gpx-rs/engine/src/engine/command/file/duplicate.rs +++ b/gpx-rs/engine/src/engine/command/file/duplicate.rs @@ -1,8 +1,8 @@ use std::{collections::HashSet, rc::Rc}; use crate::{ - Apply, CommandError, File, FileId, Selection, StackEntry, State, Track, TrackSegment, Waypoint, - WaypointChunk, + Apply, CommandError, FileId, Selection, StackEntry, State, Waypoint, copy_file, copy_segment, + copy_track, copy_waypoint, insert_waypoints, }; #[derive(Debug)] @@ -112,60 +112,23 @@ fn duplicate_waypoints( filter: impl Fn(&Waypoint) -> bool, ) -> Result { let file = files.get(&file_id).ok_or(CommandError::NothingToDo)?; - let mut file = (**file).clone(); - let last_chunk = file - .wpt - .iter() - .rposition(|chunk| chunk.wpt.iter().any(&filter)) - .ok_or(CommandError::NothingToDo)?; - - let mut copies = HashSet::new(); - let mut inserted = Vec::new(); - let mut chunk = WaypointChunk::default(); - for wpt in file + let selected: Vec<&Waypoint> = file .wpt .iter() .flat_map(|chunk| &chunk.wpt) .filter(|wpt| filter(wpt)) - { - let mut copy = wpt.clone(); - copy.id = Default::default(); - copies.insert(copy.id); - chunk.wpt.push(copy); - if chunk.is_full() { - inserted.push(Rc::new(std::mem::take(&mut chunk))); - } - } - if !chunk.wpt.is_empty() { - inserted.push(Rc::new(chunk)); - } + .collect(); + let last = selected.last().ok_or(CommandError::NothingToDo)?.id; + let copies: Vec = selected.into_iter().map(copy_waypoint).collect(); + let copy_ids = copies.iter().map(|wpt| wpt.id).collect(); - // The copies go right after the last selected waypoint: only the chunk holding it is cut - // (when waypoints follow it), all the other chunks are kept as they are. - let cut = &file.wpt[last_chunk]; - let split = cut.wpt.iter().rposition(&filter).unwrap() + 1; - 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 - }; - file.wpt.splice(last_chunk..=last_chunk, replacement); - file.wpt_rev_id = Default::default(); + // The copies go right after the last selected waypoint. + let mut file = (**file).clone(); + insert_waypoints(&mut file, Some(last), copies); files.insert(file_id, Rc::new(file)); Ok(Selection::Waypoint { file_id, - wpt_ids: copies, + wpt_ids: copy_ids, }) } @@ -178,29 +141,6 @@ fn append_copies(items: &mut Vec, filter: impl Fn(&T) -> bool, copy: impl items.splice(last + 1..last + 1, copies); } -// Track points are shared chunks, so copies are cheap. -fn copy_segment(segment: &TrackSegment) -> TrackSegment { - let mut copy = segment.clone(); - copy.id = Default::default(); - copy -} - -fn copy_track(track: &Track) -> Track { - Track { - id: Default::default(), - trkseg: track.trkseg.iter().map(copy_segment).collect(), - ..track.clone() - } -} - -fn copy_file(file: &File) -> File { - File { - id: Default::default(), - trk: file.trk.iter().map(copy_track).collect(), - ..file.clone() - } -} - #[cfg(test)] mod tests { use crate::engine::command::fixture::Fixture; diff --git a/gpx-rs/engine/src/engine/command/file/mod.rs b/gpx-rs/engine/src/engine/command/file/mod.rs index dffaa5e05..30ab68e5a 100644 --- a/gpx-rs/engine/src/engine/command/file/mod.rs +++ b/gpx-rs/engine/src/engine/command/file/mod.rs @@ -3,9 +3,11 @@ mod delete_all; mod duplicate; mod load; mod new; +mod paste; pub use delete::*; pub use delete_all::*; pub use duplicate::*; pub use load::*; pub use new::*; +pub use paste::*; diff --git a/gpx-rs/engine/src/engine/command/file/paste.rs b/gpx-rs/engine/src/engine/command/file/paste.rs new file mode 100644 index 000000000..6f484c2fc --- /dev/null +++ b/gpx-rs/engine/src/engine/command/file/paste.rs @@ -0,0 +1,852 @@ +use std::{collections::HashSet, hash::Hash, rc::Rc}; + +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, +}; + +/// Pastes what was copied or cut (see [`crate::Clipboard`]), and empties the 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 +/// like their track, or like the file they come from); +/// - a file: the tracks are added to it, segments become new tracks in it, and the waypoints are +/// added to its own; +/// - a track: the segments are added to it, tracks go after it in its file; +/// - 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. +#[derive(Debug)] +pub struct Paste; + +impl Apply for Paste { + fn apply(self, state: &mut State) -> Result<(), CommandError> { + let clipboard = state + .clipboard + .clone() + .filter(|clipboard| clipboard.can_paste(state.selection)) + .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::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)? + } + Selection::Empty => return Err(CommandError::NothingToDo), + }; + *state.selection = next; + *state.clipboard = None; + Ok(()) + } +} + +/// The last of the `selected` ids in the order of `all`: the element a paste goes after. +fn last_selected( + all: impl Iterator, + selected: &HashSet, +) -> Option { + 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 super::*; + + /// A file with the given tracks (name, number of segments) and number of waypoints. + fn file(name: &str, tracks: &[(Option<&str>, usize)], waypoints: usize) -> File { + let mut file = new_file( + name, + tracks + .iter() + .map(|(name, segments)| Track { + info: TrackInfo { + name: name.map(str::to_owned), + ..Default::default() + }, + trkseg: (0..*segments).map(|_| TrackSegment::default()).collect(), + ..Default::default() + }) + .collect(), + ); + if waypoints > 0 { + file.wpt = vec![Rc::new(crate::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 names(fx: &Fixture) -> Vec { + fx.order + .0 + .iter() + .map(|id| fx.files[id].info.name.clone()) + .collect() + } + + fn copy(fx: &mut Fixture, selection: Selection, cut: bool) { + fx.clipboard = Clipboard::new(selection, cut); + } + + fn paste(fx: &mut Fixture) -> Result<(), CommandError> { + Paste.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_paste_copied_tracks_into_a_file() { + let mut fx = Fixture::default(); + let a = add( + &mut fx, + file("a", &[(Some("t0"), 1), (Some("t1"), 2), (None, 1)], 0), + ); + let b = add(&mut fx, file("b", &[(Some("b0"), 1)], 0)); + let source = track_ids(&fx, a); + copy(&mut fx, tracks(a, &[source[0], source[1]]), false); + fx.selection = files(&[b]); + + paste(&mut fx).unwrap(); + + // the source is untouched, copies with new ids are added at the end + assert_eq!(track_ids(&fx, a), source); + let in_b = track_ids(&fx, b); + assert_eq!(in_b.len(), 3); + assert!(!source.contains(&in_b[1]) && !source.contains(&in_b[2])); + let names: Vec<_> = fx.files[&b] + .trk + .iter() + .map(|t| t.info.name.clone()) + .collect(); + assert_eq!( + names, + [Some("b0".into()), Some("t0".into()), Some("t1".into())] + ); + // 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 + assert_eq!(fx.selection, tracks(b, &in_b[1..])); + assert_eq!(fx.clipboard, None); + } + + #[test] + fn test_paste_cut_tracks_moves_them() { + 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 source = track_ids(&fx, a); + copy(&mut fx, tracks(a, &[source[0]]), true); + fx.selection = files(&[b]); + + paste(&mut fx).unwrap(); + + assert_eq!(track_ids(&fx, a), vec![source[1]]); + assert_eq!(track_ids(&fx, b), vec![source[0]]); + assert_eq!(fx.selection, tracks(b, &[source[0]])); + } + + #[test] + fn test_paste_tracks_after_the_selected_track() { + let mut fx = Fixture::default(); + let a = add( + &mut fx, + file( + "a", + &[ + (Some("0"), 1), + (Some("1"), 1), + (Some("2"), 1), + (Some("3"), 1), + ], + 0, + ), + ); + let t = track_ids(&fx, a); + // copy: the block goes after the selected track + copy(&mut fx, tracks(a, &[t[0], t[1]]), false); + fx.selection = tracks(a, &[t[2]]); + paste(&mut fx).unwrap(); + let names: Vec<_> = fx.files[&a] + .trk + .iter() + .map(|t| t.info.name.clone().unwrap()) + .collect(); + assert_eq!(names, ["0", "1", "2", "0", "1", "3"]); + + // cut: moved after the selected track + let mut fx = Fixture::default(); + let a = add( + &mut fx, + file("a", &[(Some("0"), 1), (Some("1"), 1), (Some("2"), 1)], 0), + ); + let t = track_ids(&fx, a); + copy(&mut fx, tracks(a, &[t[0]]), true); + fx.selection = tracks(a, &[t[2]]); + paste(&mut fx).unwrap(); + assert_eq!(track_ids(&fx, a), vec![t[1], t[2], t[0]]); + + // the selected track is cut itself: the tracks go at the end + let mut fx = Fixture::default(); + let a = add( + &mut fx, + file("a", &[(Some("0"), 1), (Some("1"), 1), (Some("2"), 1)], 0), + ); + let t = track_ids(&fx, a); + copy(&mut fx, tracks(a, &[t[0]]), true); + fx.selection = tracks(a, &[t[0]]); + paste(&mut fx).unwrap(); + assert_eq!(track_ids(&fx, a), vec![t[1], t[2], t[0]]); + } + + #[test] + fn test_paste_tracks_as_new_files() { + let mut fx = Fixture::default(); + let a = add(&mut fx, file("source", &[(Some("named"), 1), (None, 1)], 0)); + let t = track_ids(&fx, a); + copy(&mut fx, tracks(a, &[t[0], t[1]]), false); + fx.selection = Selection::Empty; + + paste(&mut fx).unwrap(); + + // named like the track, or like the file it comes from + assert_eq!(names(&fx), ["source", "named", "source"]); + assert_eq!(fx.files.len(), 3); + let new = &fx.order.0[1..]; + assert!( + matches!(&fx.selection, Selection::File { file_ids } if file_ids.len() == 2 && new.iter().all(|id| file_ids.contains(id))) + ); + assert_eq!(fx.files[&new[0]].trk.len(), 1); + // the source keeps its tracks + assert_eq!(track_ids(&fx, a), t); + } + + #[test] + fn test_paste_cut_tracks_as_new_files() { + let mut fx = Fixture::default(); + let a = add( + &mut fx, + file("source", &[(Some("one"), 1), (Some("two"), 1)], 0), + ); + let t = track_ids(&fx, a); + copy(&mut fx, tracks(a, &[t[0]]), true); + fx.selection = Selection::Empty; + paste(&mut fx).unwrap(); + assert_eq!(track_ids(&fx, a), vec![t[1]]); + let new = fx.order.0[1]; + assert_eq!(track_ids(&fx, new), vec![t[0]]); + } + + #[test] + fn test_paste_segments() { + let build = || { + let mut fx = Fixture::default(); + let a = add(&mut fx, file("a", &[(Some("t0"), 3), (Some("t1"), 1)], 0)); + (fx, a) + }; + + // at the end of the selected track + let (mut fx, a) = build(); + let s = segment_ids(&fx, a, 0); + let t = track_ids(&fx, a); + copy(&mut fx, segments(a, t[0], &[s[0]]), false); + fx.selection = tracks(a, &[t[1]]); + paste(&mut fx).unwrap(); + assert_eq!(segment_ids(&fx, a, 0), s); + let in_t1 = segment_ids(&fx, a, 1); + assert_eq!(in_t1.len(), 2); + assert_ne!(in_t1[1], s[0]); + assert_eq!(fx.selection, segments(a, t[1], &in_t1[1..])); + + // after the selected segment, moved + let (mut fx, a) = build(); + let s = segment_ids(&fx, a, 0); + let t = track_ids(&fx, a); + copy(&mut fx, segments(a, t[0], &[s[0]]), true); + fx.selection = segments(a, t[0], &[s[1]]); + paste(&mut fx).unwrap(); + assert_eq!(segment_ids(&fx, a, 0), vec![s[1], s[0], s[2]]); + + // each segment becomes a track of the selected file + let (mut fx, a) = build(); + let s = segment_ids(&fx, a, 0); + let t = track_ids(&fx, a); + copy(&mut fx, segments(a, t[0], &[s[0], s[1]]), false); + fx.selection = files(&[a]); + paste(&mut fx).unwrap(); + let tracks_now = track_ids(&fx, a); + assert_eq!(tracks_now.len(), 4); + assert!( + fx.files[&a].trk[2..] + .iter() + .all(|trk| trk.trkseg.len() == 1) + ); + assert_eq!(fx.selection, tracks(a, &tracks_now[2..])); + + // each segment becomes a file, named like its track + let (mut fx, a) = build(); + let s = segment_ids(&fx, a, 0); + let t = track_ids(&fx, a); + copy(&mut fx, segments(a, t[0], &[s[0], s[1]]), false); + fx.selection = Selection::Empty; + paste(&mut fx).unwrap(); + assert_eq!(names(&fx), ["a", "t0", "t0"]); + assert!( + fx.order.0[1..] + .iter() + .all(|id| fx.files[id].trk[0].trkseg.len() == 1) + ); + } + + #[test] + fn test_paste_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) + }; + + // to the end of another file + let (mut fx, a, b) = build(); + let w = wpt_ids(&fx, a); + let before = wpt_ids(&fx, b); + copy(&mut fx, waypoints(a, &[w[1], w[2]]), false); + fx.selection = files(&[b]); + paste(&mut fx).unwrap(); + assert_eq!(wpt_ids(&fx, a), w); + let now = wpt_ids(&fx, b); + assert_eq!(now.len(), 4); + assert_eq!(&now[..2], &before[..]); + assert!(!w.contains(&now[2])); + assert_eq!(fx.selection, waypoints(b, &now[2..])); + + // after the selected waypoint + let (mut fx, a, b) = build(); + let w = wpt_ids(&fx, a); + let before = wpt_ids(&fx, b); + copy(&mut fx, waypoints(a, &[w[0]]), true); + fx.selection = waypoints(b, &[before[0]]); + paste(&mut fx).unwrap(); + assert_eq!(wpt_ids(&fx, a), w[1..].to_vec()); + assert_eq!(wpt_ids(&fx, b), vec![before[0], w[0], before[1]]); + + // within the file, after a waypoint + let (mut fx, a, _) = build(); + let w = wpt_ids(&fx, a); + copy(&mut fx, waypoints(a, &[w[0], w[1]]), true); + fx.selection = waypoints(a, &[w[2]]); + paste(&mut fx).unwrap(); + assert_eq!(wpt_ids(&fx, a), vec![w[2], w[0], w[1], w[3]]); + } + + #[test] + fn test_paste_all_the_waypoints_of_a_file() { + let mut fx = Fixture::default(); + let a = add(&mut fx, file("a", &[], 3)); + let b = add(&mut fx, file("b", &[], 1)); + let w = wpt_ids(&fx, a); + copy(&mut fx, Selection::Waypoints { file_id: a }, false); + fx.selection = Selection::Waypoints { file_id: b }; + paste(&mut fx).unwrap(); + assert_eq!(wpt_ids(&fx, a), w); + assert_eq!(wpt_ids(&fx, b).len(), 4); + + // moved: the source has none left + copy(&mut fx, Selection::Waypoints { file_id: a }, true); + fx.selection = files(&[b]); + paste(&mut fx).unwrap(); + assert!(wpt_ids(&fx, a).is_empty()); + assert_eq!(&wpt_ids(&fx, b)[4..], &w[..]); + } + + #[test] + fn test_paste_files() { + let mut fx = Fixture::default(); + let a = add(&mut fx, file("a", &[(Some("t"), 1)], 1)); + let b = add(&mut fx, file("b", &[], 0)); + let c = add(&mut fx, file("c", &[], 0)); + copy(&mut fx, files(&[b, a]), false); + fx.selection = files(&[c]); + paste(&mut fx).unwrap(); + // copies, in the order of the files, at the end + assert_eq!(names(&fx), ["a", "b", "c", "a", "b"]); + assert_eq!(fx.files.len(), 5); + assert_eq!(fx.files[&fx.order.0[3]].trk.len(), 1); + assert_ne!(track_ids(&fx, fx.order.0[3]), track_ids(&fx, a)); + assert!(matches!(&fx.selection, Selection::File { file_ids } if file_ids.len() == 2)); + + // cut: moved to the end, they keep their ids + 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)); + copy(&mut fx, files(&[a]), true); + fx.selection = Selection::Empty; + paste(&mut fx).unwrap(); + assert_eq!(fx.order.0, vec![b, c, a]); + assert_eq!(fx.selection, files(&[a])); + } + + #[test] + fn test_nothing_to_paste() { + let mut fx = Fixture::default(); + let a = add(&mut fx, file("a", &[(Some("t"), 1)], 1)); + let t = track_ids(&fx, a); + let w = wpt_ids(&fx, a); + fx.selection = files(&[a]); + + // an empty clipboard + assert_eq!(paste(&mut fx), Err(CommandError::NothingToDo)); + + // a target that does not accept it (tracks cannot go to waypoints), the clipboard stays + copy(&mut fx, tracks(a, &t), false); + fx.selection = waypoints(a, &w); + assert_eq!(paste(&mut fx), Err(CommandError::NothingToDo)); + assert!(fx.clipboard.is_some()); + assert_eq!(fx.selection, waypoints(a, &w)); + + // elements that do not exist anymore + copy(&mut fx, tracks(a, &[TrackId::default()]), false); + fx.selection = files(&[a]); + assert_eq!(paste(&mut fx), Err(CommandError::NothingToDo)); + copy(&mut fx, waypoints(a, &[WaypointId::default()]), false); + assert_eq!(paste(&mut fx), Err(CommandError::NothingToDo)); + copy(&mut fx, files(&[FileId::default()]), false); + fx.selection = Selection::Empty; + assert_eq!(paste(&mut fx), Err(CommandError::NothingToDo)); + assert_eq!(fx.files.len(), 1); + assert_eq!(fx.order.0, vec![a]); + } +} diff --git a/gpx-rs/engine/src/engine/command/fixture.rs b/gpx-rs/engine/src/engine/command/fixture.rs index 39857f258..bf5747479 100644 --- a/gpx-rs/engine/src/engine/command/fixture.rs +++ b/gpx-rs/engine/src/engine/command/fixture.rs @@ -2,13 +2,14 @@ use std::collections::HashSet; -use crate::{FileId, FileOrder, Selection, StackEntry, State}; +use crate::{Clipboard, FileId, FileOrder, Selection, StackEntry, State}; #[derive(Default)] pub struct Fixture { pub files: StackEntry, pub selection: Selection, pub order: FileOrder, + pub clipboard: Option, } impl Fixture { @@ -17,6 +18,7 @@ impl Fixture { files: &mut self.files, selection: &mut self.selection, order: &mut self.order, + clipboard: &mut self.clipboard, } } diff --git a/gpx-rs/engine/src/engine/command/pattern/copy.rs b/gpx-rs/engine/src/engine/command/pattern/copy.rs new file mode 100644 index 000000000..11ebd2940 --- /dev/null +++ b/gpx-rs/engine/src/engine/command/pattern/copy.rs @@ -0,0 +1,35 @@ +use crate::{File, Track, TrackSegment, Waypoint}; + +// The trackpoints of a segment are shared chunks, so copying elements is cheap. + +/// A copy of the segment, with a new id. +pub fn copy_segment(segment: &TrackSegment) -> TrackSegment { + let mut copy = segment.clone(); + copy.id = Default::default(); + copy +} + +/// A copy of the track and of its segments, with new ids. +pub fn copy_track(track: &Track) -> Track { + Track { + id: Default::default(), + trkseg: track.trkseg.iter().map(copy_segment).collect(), + ..track.clone() + } +} + +/// A copy of the file and of its tracks and segments, with new ids. +pub fn copy_file(file: &File) -> File { + File { + id: Default::default(), + trk: file.trk.iter().map(copy_track).collect(), + ..file.clone() + } +} + +/// A copy of the waypoint, with a new id. +pub fn copy_waypoint(waypoint: &Waypoint) -> Waypoint { + let mut copy = waypoint.clone(); + copy.id = Default::default(); + copy +} diff --git a/gpx-rs/engine/src/engine/command/pattern/insert_waypoints.rs b/gpx-rs/engine/src/engine/command/pattern/insert_waypoints.rs new file mode 100644 index 000000000..f815e189e --- /dev/null +++ b/gpx-rs/engine/src/engine/command/pattern/insert_waypoints.rs @@ -0,0 +1,158 @@ +use std::rc::Rc; + +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. +pub fn insert_waypoints(file: &mut File, after: Option, waypoints: Vec) { + if waypoints.is_empty() { + return; + } + + let mut inserted = Vec::new(); + let mut chunk = WaypointChunk::default(); + for wpt in waypoints { + chunk.wpt.push(wpt); + if chunk.is_full() { + inserted.push(Rc::new(std::mem::take(&mut chunk))); + } + } + if !chunk.wpt.is_empty() { + 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)) + }) + }); + match position { + None => file.wpt.extend(inserted), + Some((i, split)) => { + 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 + }; + file.wpt.splice(i..=i, replacement); + } + } + file.wpt_rev_id = Default::default(); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn waypoint() -> Waypoint { + Waypoint::default() + } + + fn file_with(chunks: &[usize]) -> (File, Vec) { + let mut file = File::default(); + let mut ids = vec![]; + for n in chunks { + let wpt: Vec<_> = (0..*n).map(|_| waypoint()).collect(); + ids.extend(wpt.iter().map(|w| w.id)); + file.wpt.push(Rc::new(WaypointChunk { + wpt, + ..Default::default() + })); + } + (file, ids) + } + + fn ids(file: &File) -> Vec { + file.wpt + .iter() + .flat_map(|chunk| chunk.wpt.iter().map(|w| w.id)) + .collect() + } + + #[test] + fn test_insert_at_the_end() { + let (mut file, before) = file_with(&[2, 1]); + let rev = file.wpt_rev_id; + let new = vec![waypoint(), waypoint()]; + let new_ids: Vec<_> = new.iter().map(|w| w.id).collect(); + insert_waypoints(&mut file, None, new); + assert_eq!(ids(&file), [before.clone(), new_ids].concat()); + // the chunks that were there are kept + assert_eq!(file.wpt.len(), 3); + assert_ne!(file.wpt_rev_id, rev); + // an unknown waypoint is an insertion at the end too + let (mut file, before) = file_with(&[2]); + let one = waypoint(); + let one_id = one.id; + insert_waypoints(&mut file, Some(WaypointId::default()), vec![one]); + assert_eq!(ids(&file), [before, vec![one_id]].concat()); + } + + #[test] + fn test_insert_after_the_last_waypoint_of_a_chunk_keeps_the_chunks() { + let (mut file, before) = file_with(&[2, 2]); + let first = file.wpt[0].clone(); + let one = waypoint(); + let one_id = one.id; + insert_waypoints(&mut file, Some(before[1]), vec![one]); + assert_eq!(ids(&file), [&before[..2], &[one_id], &before[2..]].concat()); + // 2 chunks + the inserted one, nothing was cut + assert_eq!(file.wpt.len(), 3); + assert!(Rc::ptr_eq(&file.wpt[0], &first)); + } + + #[test] + fn test_insert_in_the_middle_of_a_chunk_cuts_it() { + let (mut file, before) = file_with(&[3, 1]); + let last = file.wpt[1].clone(); + let new = vec![waypoint(), waypoint()]; + let new_ids: Vec<_> = new.iter().map(|w| w.id).collect(); + insert_waypoints(&mut file, Some(before[0]), new); + assert_eq!( + ids(&file), + [&before[..1], &new_ids[..], &before[1..]].concat() + ); + // the cut chunk, the inserted one and the end of the cut one, then the untouched chunk + assert_eq!(file.wpt.len(), 4); + assert!(Rc::ptr_eq(&file.wpt[3], &last)); + } + + #[test] + fn test_insert_nothing() { + let (mut file, before) = file_with(&[2]); + let rev = file.wpt_rev_id; + insert_waypoints(&mut file, Some(before[0]), vec![]); + assert_eq!(ids(&file), before); + assert_eq!(file.wpt_rev_id, rev); + } + + #[test] + fn test_insert_many_fills_chunks() { + let mut file = File::default(); + let new: Vec<_> = (0..300).map(|_| waypoint()).collect(); + let new_ids: Vec<_> = new.iter().map(|w| w.id).collect(); + insert_waypoints(&mut file, None, new); + assert_eq!(ids(&file), new_ids); + assert_eq!(file.wpt.len(), 3); + assert!(file.wpt[..2].iter().all(|chunk| chunk.is_full())); + } +} diff --git a/gpx-rs/engine/src/engine/command/pattern/mod.rs b/gpx-rs/engine/src/engine/command/pattern/mod.rs index 935506fa0..7ac5a61aa 100644 --- a/gpx-rs/engine/src/engine/command/pattern/mod.rs +++ b/gpx-rs/engine/src/engine/command/pattern/mod.rs @@ -1,7 +1,11 @@ +mod copy; mod edit_waypoint_chunks; +mod insert_waypoints; mod produce; mod update_selected; +pub use copy::*; pub use edit_waypoint_chunks::*; +pub use insert_waypoints::*; pub use produce::*; pub use update_selected::*; diff --git a/gpx-rs/engine/src/engine/engine.rs b/gpx-rs/engine/src/engine/engine.rs index af29c9ecc..379b07131 100644 --- a/gpx-rs/engine/src/engine/engine.rs +++ b/gpx-rs/engine/src/engine/engine.rs @@ -1,7 +1,7 @@ // TODO license in every file use crate::{ - Action, Apply, Command, CoordinatesCache, Diff, FileId, FileOrder, FileStructure, + Action, Apply, Clipboard, Command, CoordinatesCache, Diff, FileId, FileOrder, FileStructure, FileStructureCache, GlobalStatistics, SelectMode, Selection, Stack, State, StatisticsBuffer, StatisticsCache, TrackSegmentId, }; @@ -18,6 +18,8 @@ pub struct Engine { order_changed: bool, selection_changed: bool, statistics_buffer: StatisticsBuffer, + clipboard: Option, + clipboard_changed: bool, } impl Engine { @@ -41,6 +43,23 @@ impl Engine { Some(self.statistics_cache.file_global(file)) } + /// What was copied or cut and is waiting to be pasted. + pub fn clipboard(&self) -> Option<&Clipboard> { + self.clipboard.as_ref() + } + + /// Whether the clipboard can be pasted with the current selection. + pub fn can_paste(&self) -> bool { + self.clipboard + .as_ref() + .is_some_and(|clipboard| clipboard.can_paste(&self.selection)) + } + + /// Whether the last action changed the clipboard. + pub fn clipboard_changed(&self) -> bool { + self.clipboard_changed + } + /// Whether there is something to undo. pub fn can_undo(&self) -> bool { self.stack.can_undo() @@ -86,6 +105,7 @@ impl Engine { pub fn execute(&mut self, action: Action) -> bool { let selection_before = self.selection.clone(); let order_before = self.order.0.clone(); + let clipboard_before = self.clipboard.clone(); // the files change iff there is a diff, but selection and order actions have none self.diff = match action { Action::Edit(command) => self.edit(command), @@ -128,6 +148,14 @@ impl Engine { } None } + Action::Copy => { + self.copy(false); + None + } + Action::Cut => { + self.copy(true); + None + } Action::Reorder { file_ids, index } => { self.order.move_files(&file_ids, index); None @@ -135,22 +163,32 @@ impl Engine { }; let changed = self.diff.is_some() || self.selection != selection_before - || self.order.0 != order_before; + || self.order.0 != order_before + || self.clipboard != clipboard_before; if changed { self.refresh(); } // after the refresh, which also syncs the order and selection with the files (undo, redo) self.order_changed = self.order.0 != order_before; self.selection_changed = self.selection != selection_before; + self.clipboard_changed = self.clipboard != clipboard_before; changed } + /// 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) { + self.clipboard = Some(clipboard); + } + } + fn edit(&mut self, command: Command) -> Option { self.stack.create_and_push_next(|files| { let mut state = State { files, selection: &mut self.selection, order: &mut self.order, + clipboard: &mut self.clipboard, }; command.apply(&mut state).map_err(|err| err.to_string()) }) @@ -164,10 +202,18 @@ 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); @@ -186,7 +232,9 @@ impl Engine { mod tests { use std::collections::HashSet; - use crate::{Load, Metadata, New, NewTrack, Style, TrackId, TrackSegmentId}; + use crate::{ + Delete, DeleteAll, Load, Metadata, New, NewTrack, Paste, Style, TrackId, TrackSegmentId, + }; use super::*; @@ -809,4 +857,168 @@ mod tests { assert!(engine.execute(Action::Undo)); assert_eq!(engine.order().len(), 1); } + + /// A file named `name` with `n` tracks, selected. + fn file_with_tracks(engine: &mut Engine, name: &str, n: usize) -> FileId { + new(engine, name); + for _ in 0..n { + edit(engine, Command::NewTrack(NewTrack)); + } + *engine.order().last().unwrap() + } + + fn track_ids(engine: &Engine, id: FileId) -> Vec { + engine + .file_structure(&id) + .unwrap() + .tracks + .iter() + .map(|t| t.id) + .collect() + } + + fn paste(engine: &mut Engine) -> bool { + edit(engine, Command::Paste(Paste)) + } + + #[test] + fn test_copy_and_paste() { + let mut engine = Engine::default(); + let a = file_with_tracks(&mut engine, "a", 2); + let b = file_with_tracks(&mut engine, "b", 0); + assert!(engine.clipboard().is_none() && !engine.can_paste()); + + // nothing selected, nothing copied + select_elements(&mut engine, Selection::Empty, SelectMode::Replace); + assert!(!engine.execute(Action::Copy)); + assert!(engine.clipboard().is_none()); + + let tracks = track_ids(&engine, a); + let selected_tracks = Selection::Track { + file_id: a, + trk_ids: tracks.iter().copied().collect(), + }; + select_elements(&mut engine, selected_tracks.clone(), SelectMode::Replace); + 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)) + ); + // tracks cannot be pasted onto themselves... but into a track, a file or the list of files + assert!(engine.can_paste()); + select_elements(&mut engine, files(&[b]), SelectMode::Replace); + assert!(engine.can_paste()); + select_elements( + &mut engine, + Selection::Waypoints { file_id: b }, + SelectMode::Replace, + ); + assert!(!engine.can_paste()); + select_elements(&mut engine, files(&[b]), SelectMode::Replace); + + // pasting is an edit + 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()); + 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)); + + // and it can be undone + assert!(engine.execute(Action::Undo)); + assert!(track_ids(&engine, b).is_empty()); + } + + #[test] + fn test_cut_and_paste_moves_after_the_paste() { + let mut engine = Engine::default(); + let a = file_with_tracks(&mut engine, "a", 2); + let b = file_with_tracks(&mut engine, "b", 0); + let tracks = track_ids(&engine, a); + select_elements( + &mut engine, + Selection::Track { + file_id: a, + trk_ids: [tracks[0]].into(), + }, + SelectMode::Replace, + ); + assert!(engine.execute(Action::Cut)); + assert!(engine.clipboard().is_some_and(|c| c.cut)); + // nothing moves before the paste + assert_eq!(track_ids(&engine, a), tracks); + + select_elements(&mut engine, files(&[b]), SelectMode::Replace); + assert!(paste(&mut engine)); + assert_eq!(track_ids(&engine, a), vec![tracks[1]]); + assert_eq!(track_ids(&engine, b), vec![tracks[0]]); + assert!(engine.execute(Action::Undo)); + assert_eq!(track_ids(&engine, a), tracks); + assert!(track_ids(&engine, b).is_empty()); + } + + #[test] + fn test_clipboard_drops_what_does_not_exist_anymore() { + let mut engine = Engine::default(); + let a = file_with_tracks(&mut engine, "a", 2); + let tracks = track_ids(&engine, a); + select_elements( + &mut engine, + Selection::Track { + file_id: a, + trk_ids: tracks.iter().copied().collect(), + }, + SelectMode::Replace, + ); + engine.execute(Action::Copy); + + // one of the tracks is deleted: the other one is still there to paste + select_elements( + &mut engine, + Selection::Track { + file_id: a, + trk_ids: [tracks[0]].into(), + }, + SelectMode::Replace, + ); + assert!(edit( + &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()); + } + + #[test] + fn test_copy_replaces_the_clipboard_and_does_not_change_the_history() { + let mut engine = Engine::default(); + let a = file_with_tracks(&mut engine, "a", 0); + let b = file_with_tracks(&mut engine, "b", 0); + select_files(&mut engine, &[a]); + engine.execute(Action::Copy); + select_files(&mut engine, &[b]); + assert!(engine.execute(Action::Cut)); + assert_eq!( + engine.clipboard().map(|c| (c.cut, &c.selection)), + Some((true, &files(&[b]))) + ); + // the same content again: nothing changed + 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)); + assert!(!engine.can_undo()); + } } diff --git a/gpx-rs/engine/src/engine/state/clipboard.rs b/gpx-rs/engine/src/engine/state/clipboard.rs new file mode 100644 index 000000000..391ba6a97 --- /dev/null +++ b/gpx-rs/engine/src/engine/state/clipboard.rs @@ -0,0 +1,122 @@ +use crate::Selection; + +/// 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)] +pub struct Clipboard { + pub selection: Selection, + pub cut: bool, +} + +impl Clipboard { + /// `None` if nothing is selected. + pub fn new(selection: Selection, cut: bool) -> Option { + (selection != Selection::Empty).then_some(Clipboard { selection, cut }) + } + + /// Whether the content can be pasted when `target` is selected: files and tracks can be + /// pasted anywhere above them in the file tree, and nothing selected stands for the list of + /// files. + /// + /// | copied | pasted when selected | + /// |-----------|------------------------------------------| + /// | files | nothing, a file | + /// | tracks | nothing, a file, a track | + /// | segments | nothing, a file, a track, a segment | + /// | waypoints | a file, the waypoints, a waypoint | + pub fn can_paste(&self, target: &Selection) -> bool { + use Selection::*; + matches!( + (&self.selection, target), + (File { .. }, Empty | File { .. }) + | (Track { .. }, Empty | File { .. } | Track { .. }) + | ( + TrackSegment { .. }, + Empty | File { .. } | Track { .. } | TrackSegment { .. } + ) + | ( + Waypoints { .. } | Waypoint { .. }, + File { .. } | Waypoints { .. } | Waypoint { .. } + ) + ) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use crate::{FileId, TrackId, TrackSegmentId, WaypointId}; + + use super::*; + + fn file() -> Selection { + Selection::File { + file_ids: [FileId::default()].into(), + } + } + + fn track() -> Selection { + Selection::Track { + file_id: FileId::default(), + trk_ids: [TrackId::default()].into(), + } + } + + fn segment() -> Selection { + Selection::TrackSegment { + file_id: FileId::default(), + trk_id: TrackId::default(), + trkseg_ids: [TrackSegmentId::default()].into(), + } + } + + fn waypoints() -> Selection { + Selection::Waypoints { + file_id: FileId::default(), + } + } + + fn waypoint() -> Selection { + Selection::Waypoint { + file_id: FileId::default(), + wpt_ids: HashSet::from([WaypointId::default()]), + } + } + + #[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)); + } + + #[test] + fn test_where_things_can_be_pasted() { + let targets = [ + Selection::Empty, + file(), + track(), + segment(), + waypoints(), + waypoint(), + ]; + 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]), + ]; + for (copied, allowed) in expected { + let clipboard = Clipboard::new(copied.clone(), false).unwrap(); + for (target, allowed) in targets.iter().zip(allowed) { + assert_eq!( + clipboard.can_paste(target), + allowed, + "{copied:?} on {target:?}" + ); + } + } + } +} diff --git a/gpx-rs/engine/src/engine/state/mod.rs b/gpx-rs/engine/src/engine/state/mod.rs index 2b91600fb..a74fa9dc4 100644 --- a/gpx-rs/engine/src/engine/state/mod.rs +++ b/gpx-rs/engine/src/engine/state/mod.rs @@ -1,7 +1,9 @@ +mod clipboard; mod order; mod selection; mod stack; +pub use clipboard::*; pub use order::*; pub use selection::*; pub use stack::*; @@ -10,4 +12,5 @@ pub struct State<'a> { pub files: &'a mut StackEntry, pub selection: &'a mut Selection, pub order: &'a mut FileOrder, + pub clipboard: &'a mut Option, } diff --git a/gpx-rs/wasm/src/lib.rs b/gpx-rs/wasm/src/lib.rs index c6e5ac11d..7610d9df4 100644 --- a/gpx-rs/wasm/src/lib.rs +++ b/gpx-rs/wasm/src/lib.rs @@ -358,6 +358,32 @@ pub fn can_redo() -> bool { with_engine(|e| e.can_redo()).unwrap_or(false) } +// Clipboard + +/// Puts the selected elements in the clipboard. Nothing happens when nothing is selected. +#[wasm_bindgen] +pub fn copy() -> bool { + execute(Action::Copy) +} + +/// Like `copy`, but the elements are moved when they are pasted. +#[wasm_bindgen] +pub fn cut() -> bool { + execute(Action::Cut) +} + +/// Pastes the clipboard according to the selection (see `can_paste`). +#[wasm_bindgen] +pub fn paste() -> bool { + edit(Command::Paste(engine::Paste)) +} + +/// Whether the clipboard can be pasted with the current selection. +#[wasm_bindgen] +pub fn can_paste() -> bool { + with_engine(|e| e.can_paste()).unwrap_or(false) +} + // Selection /// Selects files. `file_ids_bytes`: concatenated 16-byte UUIDs. Unknown files are ignored, and @@ -474,9 +500,15 @@ export type Selection = | { type: 'segment'; fileId: string; trackId: string; segmentIds: string[] } | { type: 'waypoints'; fileId: string } | { type: 'waypoint'; fileId: string; waypointIds: string[] }; +/** What was copied or cut, to be pasted. */ +export interface Clipboard { + selection: Selection; + cut: boolean; +} export interface FilesUpdate { orderChanged: boolean; selectionChanged: boolean; + clipboardChanged: boolean; /** Files to read the structure of. */ added: string[]; /** Files whose structure changed: reread it. */ @@ -548,6 +580,8 @@ export interface WaypointNode { extern "C" { #[wasm_bindgen(typescript_type = "Selection")] pub type Selection; + #[wasm_bindgen(typescript_type = "Clipboard | undefined")] + pub type Clipboard; #[wasm_bindgen(typescript_type = "FilesUpdate")] pub type FilesUpdate; #[wasm_bindgen(typescript_type = "FileStructure | undefined")] @@ -730,12 +764,10 @@ fn uuids(items: impl IntoIterator, uuid: impl Fn(T) -> uuid::Uuid) .collect() } -/// The current selection (ids are UUID strings). -#[wasm_bindgen] -pub fn selection() -> Selection { +fn selection_object(selection: &engine::Selection) -> Object { use engine::Selection as S; let object = Object::new(); - with_engine(|e| match e.selection() { + match selection { S::Empty => set(&object, "type", "empty"), S::File { file_ids } => { set(&object, "type", "file"); @@ -765,8 +797,31 @@ pub fn selection() -> Selection { set(&object, "fileId", file_id.0.to_string()); set(&object, "waypointIds", uuids(wpt_ids, |id| id.0)); } - }); - object.unchecked_into() + } + object +} + +/// The current selection (ids are UUID strings). +#[wasm_bindgen] +pub fn selection() -> Selection { + with_engine(|e| selection_object(e.selection())) + .unwrap_or_default() + .unchecked_into() +} + +/// What was copied or cut and is waiting to be pasted, `undefined` if nothing. +#[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)); + set(&object, "cut", clipboard.cut); + object.into() + }) + }) + .unwrap_or(JsValue::UNDEFINED) + .unchecked_into() } /// What the last action changed. Read it right after each action. @@ -777,6 +832,7 @@ pub fn last_update() -> FilesUpdate { let diff = e.last_diff().cloned().unwrap_or_default(); set(&update, "orderChanged", e.order_changed()); set(&update, "selectionChanged", e.selection_changed()); + set(&update, "clipboardChanged", e.clipboard_changed()); set(&update, "added", ids(&diff.added)); set(&update, "modified", ids(&diff.modified)); set(&update, "removed", ids(&diff.removed)); diff --git a/website/src/lib/components/Menu.svelte b/website/src/lib/components/Menu.svelte index 1c2d04958..a39f0561c 100644 --- a/website/src/lib/components/Menu.svelte +++ b/website/src/lib/components/Menu.svelte @@ -53,27 +53,19 @@ import { anySelectedLayer } from '$lib/components/map/layer-control/utils'; import { defaultOverlays } from '$lib/assets/layers'; import LayerControlSettings from '$lib/components/map/layer-control/LayerControlSettings.svelte'; - import { ListLevel } from '$lib/components/file-list/file-list'; import Export from '$lib/components/export/Export.svelte'; import { mode, setMode } from 'mode-watcher'; import { i18n } from '$lib/i18n.svelte'; import { languages } from '$lib/languages'; import { getURLForLanguage } from '$lib/utils'; import { settings } from '$lib/logic/settings'; - import { - createFile, - loadFiles, - pasteSelection, - triggerFileInput, - } from '$lib/logic/file-actions'; + import { createFile, loadFiles, triggerFileInput } from '$lib/logic/file-actions'; import { engine } from '$lib/engine'; import { allHidden } from '$lib/all-hidden'; import { selectionSize } from '$lib/selection-helpers'; - // TODO the clipboard and centering on the selection still work on the previous implementation - import { copied, selection as oldSelection } from '$lib/logic/selection'; + // TODO centering on the selection still works on the previous implementation import { boundsManager } from '$lib/logic/bounds'; import { onMount } from 'svelte'; - import { allowedPastes } from '$lib/components/file-list/sortable-file-list'; const { distanceUnits, @@ -91,22 +83,12 @@ routing, } = settings; - const { files, selection, canUndo, canRedo } = engine; + const { files, selection, canUndo, canRedo, canPaste } = engine; let selectionCount = $derived(selectionSize($selection)); let noFiles = $derived($files.size === 0); // metadata and style can be edited for files and tracks only let filesOrTracksSelected = $derived($selection.type === 'file' || $selection.type === 'track'); - let selectionLevel = $derived( - { - empty: undefined, - file: ListLevel.FILE, - track: ListLevel.TRACK, - segment: ListLevel.SEGMENT, - waypoints: ListLevel.WAYPOINTS, - waypoint: ListLevel.WAYPOINT, - }[$selection.type] - ); function switchBasemaps() { [$currentBasemap, $previousBasemap] = [$previousBasemap, $currentBasemap]; @@ -291,29 +273,17 @@ {#if $treeFileView} - oldSelection.copySelection()} - disabled={selectionCount === 0} - > + engine.copy()} disabled={selectionCount === 0}> {i18n._('menu.copy')} - oldSelection.cutSelection()} - disabled={selectionCount === 0} - > + engine.cut()} disabled={selectionCount === 0}> {i18n._('menu.cut')} - + engine.paste()}> {i18n._('menu.paste')} @@ -558,17 +528,17 @@ e.preventDefault(); } else if (e.key === 'c' && (e.metaKey || e.ctrlKey)) { if (!targetInput) { - oldSelection.copySelection(); + engine.copy(); e.preventDefault(); } } else if (e.key === 'x' && (e.metaKey || e.ctrlKey)) { if (!targetInput) { - oldSelection.cutSelection(); + engine.cut(); e.preventDefault(); } } else if (e.key === 'v' && (e.metaKey || e.ctrlKey)) { if (!targetInput) { - pasteSelection(); + engine.paste(); e.preventDefault(); } } else if ((e.key === 's' || e.key == 'S') && (e.metaKey || e.ctrlKey)) { diff --git a/website/src/lib/components/file-list/FileList.svelte b/website/src/lib/components/file-list/FileList.svelte index f65e0e893..1d50b415c 100644 --- a/website/src/lib/components/file-list/FileList.svelte +++ b/website/src/lib/components/file-list/FileList.svelte @@ -3,16 +3,12 @@ import * as ContextMenu from '$lib/components/ui/context-menu'; import FileListNodeContent from './FileListNodeContent.svelte'; import { onMount, setContext } from 'svelte'; - import { ListLevel } from './file-list'; import { ClipboardPaste, FileStack, Plus } from '@lucide/svelte'; import Shortcut from '$lib/components/Shortcut.svelte'; import { i18n } from '$lib/i18n.svelte'; import { engine } from '$lib/engine'; import { selectedFileIds } from '$lib/selection-helpers'; - import { createFile, pasteSelection } from '$lib/logic/file-actions'; - // TODO the clipboard still works on the previous implementation - import { copied } from '$lib/logic/selection'; - import { allowedPastes } from './sortable-file-list'; + import { createFile } from '$lib/logic/file-actions'; let { orientation, @@ -29,7 +25,7 @@ setContext('orientation', orientation); setContext('recursive', recursive); - const { files, selection } = engine; + const { files, selection, canPaste } = engine; onMount(() => { if (orientation === 'horizontal' && $selection.type !== 'file') { @@ -71,12 +67,7 @@ - + engine.paste()}> {i18n._('menu.paste')} diff --git a/website/src/lib/components/file-list/FileListNodeLabel.svelte b/website/src/lib/components/file-list/FileListNodeLabel.svelte index 468ffb7c0..85d265446 100644 --- a/website/src/lib/components/file-list/FileListNodeLabel.svelte +++ b/website/src/lib/components/file-list/FileListNodeLabel.svelte @@ -37,15 +37,12 @@ selectionSize, type FileTreeNode, } from '$lib/selection-helpers'; - // TODO the clipboard, centering on the selection and the waypoint popup still work on the - // previous implementation - import { copied, selection as oldSelection } from '$lib/logic/selection'; - import { pasteSelection } from '$lib/logic/file-actions'; + // TODO centering on the selection and the waypoint popup still work on the previous + // implementation import { boundsManager } from '$lib/logic/bounds'; // import { gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers'; // import { fileStateCollection } from '$lib/logic/file-state'; // import { waypointPopup } from '$lib/components/map/gpx-layer/gpx-layer-popup'; - import { allowedPastes } from './sortable-file-list'; let { fileState, @@ -57,7 +54,7 @@ label: string | undefined; } = $props(); - const { selection } = engine; + const { selection, clipboard, canPaste } = engine; let orientation = getContext<'vertical' | 'horizontal'>('orientation'); let embedding = getContext('embedding'); @@ -107,6 +104,8 @@ ); 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)); @@ -150,7 +149,7 @@ { if (embedding) { e.preventDefault(); @@ -278,22 +277,17 @@ {#if orientation === 'vertical'} - oldSelection.copySelection()}> + engine.copy()}> {i18n._('menu.copy')} - oldSelection.cutSelection()}> + engine.cut()}> {i18n._('menu.cut')} - + engine.paste()}> {i18n._('menu.paste')} 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 91327d4dc..b5e2a68dc 100644 --- a/website/src/lib/components/file-list/sortable-file-list.ts +++ b/website/src/lib/components/file-list/sortable-file-list.ts @@ -20,15 +20,6 @@ export const allowedMoves: Record = { [ListLevel.WAYPOINT]: [ListLevel.WAYPOINTS, ListLevel.WAYPOINT], }; -export const allowedPastes: Record = { - [ListLevel.ROOT]: [], - [ListLevel.FILE]: [ListLevel.ROOT, ListLevel.FILE], - [ListLevel.TRACK]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK], - [ListLevel.SEGMENT]: [ListLevel.ROOT, ListLevel.FILE, ListLevel.TRACK, ListLevel.SEGMENT], - [ListLevel.WAYPOINTS]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT], - [ListLevel.WAYPOINT]: [ListLevel.FILE, ListLevel.WAYPOINTS, ListLevel.WAYPOINT], -}; - export const dragging = writable(null); /** diff --git a/website/src/lib/engine.ts b/website/src/lib/engine.ts index fecb428cb..a02066bf3 100644 --- a/website/src/lib/engine.ts +++ b/website/src/lib/engine.ts @@ -4,9 +4,10 @@ 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 { GlobalStatistics, FileStructure, Selection } from 'gpx-rs'; +import type { Clipboard, GlobalStatistics, FileStructure, Selection } from 'gpx-rs'; export type { + Clipboard, GlobalStatistics, FileStructure, FilesUpdate, @@ -199,6 +200,8 @@ class Engine { private _selection = writable({ type: 'empty' }); private _canUndo = writable(false); private _canRedo = writable(false); + private _canPaste = writable(false); + private _clipboard = writable(undefined); private _statistics = writable(EMPTY_SELECTION_STATISTICS); /** Identifies the statistics currently in the engine's buffers. */ private _statisticsVersion = 0; @@ -209,6 +212,10 @@ class Engine { readonly statistics: Readable = { subscribe: this._statistics.subscribe }; readonly canUndo: Readable = { subscribe: this._canUndo.subscribe }; readonly canRedo: Readable = { subscribe: this._canRedo.subscribe }; + /** What was copied or cut, waiting to be pasted. */ + readonly clipboard: Readable = { subscribe: this._clipboard.subscribe }; + /** Whether the clipboard can be pasted with the current selection. */ + readonly canPaste: Readable = { subscribe: this._canPaste.subscribe }; /** What is currently selected. */ readonly selection: Readable = { subscribe: this._selection.subscribe }; /** @@ -338,6 +345,28 @@ class Engine { } } + // Clipboard + + /** Puts the selected elements in the clipboard. */ + copy() { + return this.run((w) => w.copy()); + } + + /** Like `copy`, but the elements are moved when they are pasted. */ + cut() { + return this.run((w) => w.cut()); + } + + /** + * 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. + */ + paste() { + return this.run((w) => w.paste()); + } + // Edits of the selection newTrack() { @@ -408,6 +437,10 @@ class Engine { const update = wasm.last_update(); this._canUndo.set(wasm.can_undo()); this._canRedo.set(wasm.can_redo()); + this._canPaste.set(wasm.can_paste()); + if (update.clipboardChanged) { + this._clipboard.set(wasm.clipboard()); + } if (update.selectionChanged) { this._selection.set(wasm.selection()); }