This commit is contained in:
vcoppe
2026-10-04 15:47:09 +02:00
parent bf946f179a
commit ec448b72e2
10 changed files with 1079 additions and 449 deletions
@@ -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 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))?
impl From<MoveTarget> 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))
}
(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 {
MoveTarget::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)?
} => Destination::Segments(file_id, trk_id, Place::Index(index)),
MoveTarget::Waypoints { file_id, index } => {
Destination::Waypoints(file_id, Place::Index(index))
}
(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;
}
impl Apply for Move {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
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(())
}
}
+332 -92
View File
@@ -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,93 +33,99 @@ 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))
// 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;
}
_ => return Err(CommandError::NothingToDo),
};
transfer_tracks(state, *file_id, trk_ids, cut, to)?
}
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(())
}
}
fn track_ids_of(state: &State, file_id: FileId) -> Vec<TrackId> {
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<TrackSegmentId> {
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<WaypointId> {
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<Destination, CommandError> {
let nothing = CommandError::NothingToDo;
let last_file = |file_ids: &HashSet<FileId>| {
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 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.
fn last_selected<T: Copy + Eq + Hash>(
all: impl Iterator<Item = T>,
@@ -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::<HashSet<_>>().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))
);
}
}
+210 -206
View File
@@ -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<T: Copy + PartialEq> Place<T> {
}
}
/// 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<FileId>),
Tracks(FileId, Place<TrackId>),
Segments(FileId, TrackId, Place<TrackSegmentId>),
Waypoints(FileId, Place<WaypointId>),
}
pub fn waypoint_ids(file: &File) -> impl Iterator<Item = WaypointId> + '_ {
file.wpt
.iter()
@@ -60,223 +76,227 @@ pub fn new_file(name: &str, trk: Vec<Track>) -> File {
}
/// Adds the files to the state, at `place` in the order of the files.
fn add_files(state: &mut State, files: Vec<File>, place: Place<FileId>) -> HashSet<FileId> {
fn add_files(state: &mut State, files: Vec<Rc<File>>, place: Place<FileId>) -> Selection {
let at = place.position(&state.order.0);
let ids: Vec<FileId> = 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<FileId>,
cut: bool,
place: Place<FileId>,
) -> Result<Selection, CommandError> {
let ids: Vec<FileId> = 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<FileId> = 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<TrackId> = 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<TrackSegmentId> = 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<WaypointId> = 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<FileId> = 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<Selection, CommandError> {
// 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<FileId>),
/// The tracks go in a file.
File(FileId, Place<TrackId>),
}
/// Copies the tracks, or moves them (with their ids) when `cut`.
pub fn transfer_tracks(
state: &mut State,
file_id: FileId,
source: &HashSet<TrackId>,
cut: bool,
to: TracksTo,
) -> Result<Selection, CommandError> {
let source_file = state.files.get(&file_id).ok_or(CommandError::NothingToDo)?;
let source_name = source_file.info.name.clone();
let tracks: Vec<Track> = 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<Track> = if cut {
tracks
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 {
tracks.iter().map(copy_track).collect()
};
let ids: HashSet<TrackId> = tracks.iter().map(|trk| trk.id).collect();
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<Selection, CommandError> {
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<Track> = tracks.into_iter().map(|(track, _)| track).collect();
let ids: HashSet<TrackId> = tracks.iter().map(|trk| trk.id).collect();
let file = file_mut(state, file_id)?;
let existing: Vec<TrackId> = 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<FileId>),
/// Each segment becomes a new track of the file.
Tracks(FileId, Place<TrackId>),
/// The segments go in a track of a file.
Track(FileId, TrackId, Place<TrackSegmentId>),
}
/// 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<TrackSegmentId>,
cut: bool,
to: SegmentsTo,
segments: &[ClipboardSegment],
copy: bool,
to: Destination,
) -> Result<Selection, CommandError> {
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<TrackSegment> = source_track
.trkseg
.iter()
.filter(|seg| source.contains(&seg.id))
.cloned()
.collect();
if segments.is_empty() {
return Err(CommandError::NothingToDo);
}
let destination_exists = match to {
SegmentsTo::Files(_) => true,
SegmentsTo::Tracks(id, _) => state.files.contains_key(&id),
SegmentsTo::Track(id, trk, _) => state
.files
.get(&id)
.is_some_and(|file| file.trk.iter().any(|t| t.id == trk)),
};
if !destination_exists {
return Err(CommandError::NothingToDo);
}
if cut {
let file = file_mut(state, file_id)?;
if let Some(trk) = file.trk.iter_mut().find(|trk| trk.id == trk_id) {
trk.trkseg.retain(|seg| !source.contains(&seg.id));
}
}
let segments: Vec<TrackSegment> = if cut {
segments
.map(|clip| {
let segment = if copy {
copy_segment(&clip.segment)
} else {
segments.iter().map(copy_segment).collect()
clip.segment.clone()
};
let ids: HashSet<TrackSegmentId> = segments.iter().map(|seg| seg.id).collect();
(segment, clip.name.as_str())
})
.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<Track> = 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<Track> = 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<TrackId> = 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<TrackSegment> =
segments.into_iter().map(|(segment, _)| segment).collect();
let ids: HashSet<TrackSegmentId> = 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<TrackSegmentId> = 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<WaypointId>>,
cut: bool,
to: FileId,
place: Place<WaypointId>,
) -> Result<Selection, CommandError> {
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<Waypoint> = source_file
.wpt
let waypoints: Vec<Waypoint> = waypoints
.iter()
.flat_map(|chunk| &chunk.wpt)
.filter(|wpt| selected(wpt))
.cloned()
.collect();
if waypoints.is_empty() || !state.files.contains_key(&to) {
return Err(CommandError::NothingToDo);
}
if cut {
edit_waypoint_chunks(file_mut(state, file_id)?, selected, |wpts| {
wpts.retain(|wpt| !selected(wpt));
true
});
}
let waypoints: Vec<Waypoint> = if cut {
waypoints
.map(|wpt| {
if copy {
copy_waypoint(wpt)
} else {
waypoints.iter().map(copy_waypoint).collect()
};
wpt.clone()
}
})
.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<WaypointId> = waypoint_ids(file).collect();
insert_waypoints_at(file, place.position(&existing), waypoints);
Ok(Selection::Waypoint {
file_id: to,
file_id,
wpt_ids: ids,
})
}
+39 -29
View File
@@ -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));
+405 -48
View File
@@ -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<Rc<File>>),
Tracks(Vec<ClipboardTrack>),
Segments(Vec<ClipboardSegment>),
Waypoints(Vec<Waypoint>),
}
#[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<Clipboard> {
(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<Clipboard> {
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<FileId>),
Tracks(Vec<TrackId>),
Segments(Vec<TrackSegmentId>),
Waypoints(Vec<WaypointId>),
}
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<File>) -> StackEntry {
files
.into_iter()
.map(|file| (file.id, Rc::new(file)))
.collect()
}
fn order_of(files: &StackEntry) -> Vec<FileId> {
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()]);
// 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<_>>(),
vec![t0, t2]
);
assert!(copied.iter().all(|c| c.file_name == "source"));
}
other => panic!("{other:?}"),
}
fn waypoints() -> Selection {
Selection::Waypoints {
file_id: FileId::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<_>>(),
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:?}"),
}
fn waypoint() -> Selection {
Selection::Waypoint {
file_id: FileId::default(),
wpt_ids: HashSet::from([WaypointId::default()]),
}
// 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),
+17 -5
View File
@@ -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()
})
@@ -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));
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
+5 -1
View File
@@ -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());
+22 -1
View File
@@ -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);
+20 -1
View File
@@ -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) {