This commit is contained in:
vcoppe
2026-10-04 15:03:37 +02:00
parent 55f1d275b7
commit 9ff9b5475a
18 changed files with 1531 additions and 159 deletions
+5
View File
@@ -26,6 +26,11 @@ pub enum Action<'a> {
down: bool, down: bool,
add: 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. /// Moves the files to `index` among the other files, in the given order. Not undoable.
Reorder { Reorder {
file_ids: Vec<FileId>, file_ids: Vec<FileId>,
+4 -2
View File
@@ -1,8 +1,8 @@
use crate::{Apply, CommandError, State}; use crate::{Apply, CommandError, State};
use crate::{ use crate::{
Clean, Crop, Delete, DeleteAll, Duplicate, Elevation, Extract, Load, Merge, Metadata, Clean, Crop, Delete, DeleteAll, Duplicate, Elevation, Extract, Load, Merge, Metadata,
MoveWaypoint, New, NewTrack, NewTrackSegment, NewWaypoint, Reverse, SpliceTrackpoints, Split, MoveWaypoint, New, NewTrack, NewTrackSegment, NewWaypoint, Paste, Reverse, SpliceTrackpoints,
Style, Time, Split, Style, Time,
}; };
/// A user action that edits the files. Each variant wraps the command's own struct, whose /// 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>), Load(Load<'a>),
Duplicate(Duplicate), Duplicate(Duplicate),
Delete(Delete), Delete(Delete),
Paste(Paste),
DeleteAll(DeleteAll), DeleteAll(DeleteAll),
Metadata(Metadata<'a>), Metadata(Metadata<'a>),
Style(Style<'a>), Style(Style<'a>),
@@ -41,6 +42,7 @@ impl Apply for Command<'_> {
Command::Load(c) => c.apply(state), Command::Load(c) => c.apply(state),
Command::Duplicate(c) => c.apply(state), Command::Duplicate(c) => c.apply(state),
Command::Delete(c) => c.apply(state), Command::Delete(c) => c.apply(state),
Command::Paste(c) => c.apply(state),
Command::DeleteAll(c) => c.apply(state), Command::DeleteAll(c) => c.apply(state),
Command::Metadata(c) => c.apply(state), Command::Metadata(c) => c.apply(state),
Command::Style(c) => c.apply(state), Command::Style(c) => c.apply(state),
@@ -1,8 +1,8 @@
use std::{collections::HashSet, rc::Rc}; use std::{collections::HashSet, rc::Rc};
use crate::{ use crate::{
Apply, CommandError, File, FileId, Selection, StackEntry, State, Track, TrackSegment, Waypoint, Apply, CommandError, FileId, Selection, StackEntry, State, Waypoint, copy_file, copy_segment,
WaypointChunk, copy_track, copy_waypoint, insert_waypoints,
}; };
#[derive(Debug)] #[derive(Debug)]
@@ -112,60 +112,23 @@ fn duplicate_waypoints(
filter: impl Fn(&Waypoint) -> bool, filter: impl Fn(&Waypoint) -> bool,
) -> Result<Selection, CommandError> { ) -> Result<Selection, CommandError> {
let file = files.get(&file_id).ok_or(CommandError::NothingToDo)?; let file = files.get(&file_id).ok_or(CommandError::NothingToDo)?;
let mut file = (**file).clone(); let selected: Vec<&Waypoint> = file
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
.wpt .wpt
.iter() .iter()
.flat_map(|chunk| &chunk.wpt) .flat_map(|chunk| &chunk.wpt)
.filter(|wpt| filter(wpt)) .filter(|wpt| filter(wpt))
{ .collect();
let mut copy = wpt.clone(); let last = selected.last().ok_or(CommandError::NothingToDo)?.id;
copy.id = Default::default(); let copies: Vec<Waypoint> = selected.into_iter().map(copy_waypoint).collect();
copies.insert(copy.id); let copy_ids = copies.iter().map(|wpt| wpt.id).collect();
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));
}
// The copies go right after the last selected waypoint: only the chunk holding it is cut // The copies go right after the last selected waypoint.
// (when waypoints follow it), all the other chunks are kept as they are. let mut file = (**file).clone();
let cut = &file.wpt[last_chunk]; insert_waypoints(&mut file, Some(last), copies);
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();
files.insert(file_id, Rc::new(file)); files.insert(file_id, Rc::new(file));
Ok(Selection::Waypoint { Ok(Selection::Waypoint {
file_id, file_id,
wpt_ids: copies, wpt_ids: copy_ids,
}) })
} }
@@ -178,29 +141,6 @@ fn append_copies<T>(items: &mut Vec<T>, filter: impl Fn(&T) -> bool, copy: impl
items.splice(last + 1..last + 1, copies); 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)] #[cfg(test)]
mod tests { mod tests {
use crate::engine::command::fixture::Fixture; use crate::engine::command::fixture::Fixture;
@@ -3,9 +3,11 @@ mod delete_all;
mod duplicate; mod duplicate;
mod load; mod load;
mod new; mod new;
mod paste;
pub use delete::*; pub use delete::*;
pub use delete_all::*; pub use delete_all::*;
pub use duplicate::*; pub use duplicate::*;
pub use load::*; pub use load::*;
pub use new::*; pub use new::*;
pub use paste::*;
@@ -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<T: Copy + Eq + Hash>(
all: impl Iterator<Item = T>,
selected: &HashSet<T>,
) -> Option<T> {
all.filter(|id| selected.contains(id)).last()
}
fn waypoint_ids(file: &File) -> impl Iterator<Item = WaypointId> + '_ {
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<Track>) -> File {
let mut file = File {
trk,
..Default::default()
};
file.info.name = name.to_owned();
file
}
fn paste_files(
state: &mut State,
source: &HashSet<FileId>,
cut: bool,
) -> 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);
}
let pasted = if cut {
// moved to the end of the list
state.order.move_files(&ids, usize::MAX);
ids
} else {
let copies: Vec<File> = 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<TrackId>,
cut: bool,
) -> 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);
}
// 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<Track> = if cut {
tracks
} else {
tracks.iter().map(copy_track).collect()
};
let ids: HashSet<TrackId> = 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::<Vec<_>>();
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<TrackSegmentId>,
cut: bool,
) -> Result<Selection, CommandError> {
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<TrackSegment> = 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<TrackSegment> = if cut {
segments
} else {
segments.iter().map(copy_segment).collect()
};
let ids: HashSet<TrackSegmentId> = 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<TrackSegmentId>,
segments: Vec<TrackSegment>|
-> 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::<Vec<_>>();
Selection::File {
file_ids: files
.into_iter()
.map(|file| add_file(state, file))
.collect(),
}
}
To::Tracks(id) => {
let tracks: Vec<Track> = 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<WaypointId>>,
cut: bool,
) -> Result<Selection, CommandError> {
// 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<Waypoint> = 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<Waypoint> = 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<TrackId> {
fx.files[&id].trk.iter().map(|trk| trk.id).collect()
}
fn segment_ids(fx: &Fixture, id: FileId, trk: usize) -> Vec<TrackSegmentId> {
fx.files[&id].trk[trk].trkseg.iter().map(|s| s.id).collect()
}
fn wpt_ids(fx: &Fixture, id: FileId) -> Vec<WaypointId> {
waypoint_ids(&fx.files[&id]).collect()
}
fn names(fx: &Fixture) -> Vec<String> {
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]);
}
}
+3 -1
View File
@@ -2,13 +2,14 @@
use std::collections::HashSet; use std::collections::HashSet;
use crate::{FileId, FileOrder, Selection, StackEntry, State}; use crate::{Clipboard, FileId, FileOrder, Selection, StackEntry, State};
#[derive(Default)] #[derive(Default)]
pub struct Fixture { pub struct Fixture {
pub files: StackEntry, pub files: StackEntry,
pub selection: Selection, pub selection: Selection,
pub order: FileOrder, pub order: FileOrder,
pub clipboard: Option<Clipboard>,
} }
impl Fixture { impl Fixture {
@@ -17,6 +18,7 @@ impl Fixture {
files: &mut self.files, files: &mut self.files,
selection: &mut self.selection, selection: &mut self.selection,
order: &mut self.order, order: &mut self.order,
clipboard: &mut self.clipboard,
} }
} }
@@ -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
}
@@ -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<WaypointId>, waypoints: Vec<Waypoint>) {
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<WaypointId>) {
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<WaypointId> {
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()));
}
}
@@ -1,7 +1,11 @@
mod copy;
mod edit_waypoint_chunks; mod edit_waypoint_chunks;
mod insert_waypoints;
mod produce; mod produce;
mod update_selected; mod update_selected;
pub use copy::*;
pub use edit_waypoint_chunks::*; pub use edit_waypoint_chunks::*;
pub use insert_waypoints::*;
pub use produce::*; pub use produce::*;
pub use update_selected::*; pub use update_selected::*;
+215 -3
View File
@@ -1,7 +1,7 @@
// TODO license in every file // TODO license in every file
use crate::{ 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, FileStructureCache, GlobalStatistics, SelectMode, Selection, Stack, State, StatisticsBuffer,
StatisticsCache, TrackSegmentId, StatisticsCache, TrackSegmentId,
}; };
@@ -18,6 +18,8 @@ pub struct Engine {
order_changed: bool, order_changed: bool,
selection_changed: bool, selection_changed: bool,
statistics_buffer: StatisticsBuffer, statistics_buffer: StatisticsBuffer,
clipboard: Option<Clipboard>,
clipboard_changed: bool,
} }
impl Engine { impl Engine {
@@ -41,6 +43,23 @@ impl Engine {
Some(self.statistics_cache.file_global(file)) 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. /// Whether there is something to undo.
pub fn can_undo(&self) -> bool { pub fn can_undo(&self) -> bool {
self.stack.can_undo() self.stack.can_undo()
@@ -86,6 +105,7 @@ impl Engine {
pub fn execute(&mut self, action: Action) -> bool { pub fn execute(&mut self, action: Action) -> bool {
let selection_before = self.selection.clone(); let selection_before = self.selection.clone();
let order_before = self.order.0.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 // the files change iff there is a diff, but selection and order actions have none
self.diff = match action { self.diff = match action {
Action::Edit(command) => self.edit(command), Action::Edit(command) => self.edit(command),
@@ -128,6 +148,14 @@ impl Engine {
} }
None None
} }
Action::Copy => {
self.copy(false);
None
}
Action::Cut => {
self.copy(true);
None
}
Action::Reorder { file_ids, index } => { Action::Reorder { file_ids, index } => {
self.order.move_files(&file_ids, index); self.order.move_files(&file_ids, index);
None None
@@ -135,22 +163,32 @@ impl Engine {
}; };
let changed = self.diff.is_some() let changed = self.diff.is_some()
|| self.selection != selection_before || self.selection != selection_before
|| self.order.0 != order_before; || self.order.0 != order_before
|| self.clipboard != clipboard_before;
if changed { if changed {
self.refresh(); self.refresh();
} }
// after the refresh, which also syncs the order and selection with the files (undo, redo) // after the refresh, which also syncs the order and selection with the files (undo, redo)
self.order_changed = self.order.0 != order_before; self.order_changed = self.order.0 != order_before;
self.selection_changed = self.selection != selection_before; self.selection_changed = self.selection != selection_before;
self.clipboard_changed = self.clipboard != clipboard_before;
changed 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<Diff> { fn edit(&mut self, command: Command) -> Option<Diff> {
self.stack.create_and_push_next(|files| { self.stack.create_and_push_next(|files| {
let mut state = State { let mut state = State {
files, files,
selection: &mut self.selection, selection: &mut self.selection,
order: &mut self.order, order: &mut self.order,
clipboard: &mut self.clipboard,
}; };
command.apply(&mut state).map_err(|err| err.to_string()) 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 // files coming back after an undo/redo are added at the end of the order
self.order.sync(files); self.order.sync(files);
self.selection.retain_existing(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 => { None => {
self.order.0.clear(); self.order.0.clear();
self.selection = Selection::Empty; self.selection = Selection::Empty;
self.clipboard = None;
} }
} }
self.statistics_cache.update(current); self.statistics_cache.update(current);
@@ -186,7 +232,9 @@ impl Engine {
mod tests { mod tests {
use std::collections::HashSet; 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::*; use super::*;
@@ -809,4 +857,168 @@ mod tests {
assert!(engine.execute(Action::Undo)); assert!(engine.execute(Action::Undo));
assert_eq!(engine.order().len(), 1); 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<TrackId> {
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());
}
} }
+122
View File
@@ -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<Clipboard> {
(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:?}"
);
}
}
}
}
+3
View File
@@ -1,7 +1,9 @@
mod clipboard;
mod order; mod order;
mod selection; mod selection;
mod stack; mod stack;
pub use clipboard::*;
pub use order::*; pub use order::*;
pub use selection::*; pub use selection::*;
pub use stack::*; pub use stack::*;
@@ -10,4 +12,5 @@ pub struct State<'a> {
pub files: &'a mut StackEntry, pub files: &'a mut StackEntry,
pub selection: &'a mut Selection, pub selection: &'a mut Selection,
pub order: &'a mut FileOrder, pub order: &'a mut FileOrder,
pub clipboard: &'a mut Option<Clipboard>,
} }
+62 -6
View File
@@ -358,6 +358,32 @@ pub fn can_redo() -> bool {
with_engine(|e| e.can_redo()).unwrap_or(false) 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 // Selection
/// Selects files. `file_ids_bytes`: concatenated 16-byte UUIDs. Unknown files are ignored, and /// 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: 'segment'; fileId: string; trackId: string; segmentIds: string[] }
| { type: 'waypoints'; fileId: string } | { type: 'waypoints'; fileId: string }
| { type: 'waypoint'; fileId: string; waypointIds: 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 { export interface FilesUpdate {
orderChanged: boolean; orderChanged: boolean;
selectionChanged: boolean; selectionChanged: boolean;
clipboardChanged: boolean;
/** Files to read the structure of. */ /** Files to read the structure of. */
added: string[]; added: string[];
/** Files whose structure changed: reread it. */ /** Files whose structure changed: reread it. */
@@ -548,6 +580,8 @@ export interface WaypointNode {
extern "C" { extern "C" {
#[wasm_bindgen(typescript_type = "Selection")] #[wasm_bindgen(typescript_type = "Selection")]
pub type Selection; pub type Selection;
#[wasm_bindgen(typescript_type = "Clipboard | undefined")]
pub type Clipboard;
#[wasm_bindgen(typescript_type = "FilesUpdate")] #[wasm_bindgen(typescript_type = "FilesUpdate")]
pub type FilesUpdate; pub type FilesUpdate;
#[wasm_bindgen(typescript_type = "FileStructure | undefined")] #[wasm_bindgen(typescript_type = "FileStructure | undefined")]
@@ -730,12 +764,10 @@ fn uuids<T>(items: impl IntoIterator<Item = T>, uuid: impl Fn(T) -> uuid::Uuid)
.collect() .collect()
} }
/// The current selection (ids are UUID strings). fn selection_object(selection: &engine::Selection) -> Object {
#[wasm_bindgen]
pub fn selection() -> Selection {
use engine::Selection as S; use engine::Selection as S;
let object = Object::new(); let object = Object::new();
with_engine(|e| match e.selection() { match selection {
S::Empty => set(&object, "type", "empty"), S::Empty => set(&object, "type", "empty"),
S::File { file_ids } => { S::File { file_ids } => {
set(&object, "type", "file"); set(&object, "type", "file");
@@ -765,8 +797,31 @@ pub fn selection() -> Selection {
set(&object, "fileId", file_id.0.to_string()); set(&object, "fileId", file_id.0.to_string());
set(&object, "waypointIds", uuids(wpt_ids, |id| id.0)); 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. /// 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(); let diff = e.last_diff().cloned().unwrap_or_default();
set(&update, "orderChanged", e.order_changed()); set(&update, "orderChanged", e.order_changed());
set(&update, "selectionChanged", e.selection_changed()); set(&update, "selectionChanged", e.selection_changed());
set(&update, "clipboardChanged", e.clipboard_changed());
set(&update, "added", ids(&diff.added)); set(&update, "added", ids(&diff.added));
set(&update, "modified", ids(&diff.modified)); set(&update, "modified", ids(&diff.modified));
set(&update, "removed", ids(&diff.removed)); set(&update, "removed", ids(&diff.removed));
+9 -39
View File
@@ -53,27 +53,19 @@
import { anySelectedLayer } from '$lib/components/map/layer-control/utils'; import { anySelectedLayer } from '$lib/components/map/layer-control/utils';
import { defaultOverlays } from '$lib/assets/layers'; import { defaultOverlays } from '$lib/assets/layers';
import LayerControlSettings from '$lib/components/map/layer-control/LayerControlSettings.svelte'; 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 Export from '$lib/components/export/Export.svelte';
import { mode, setMode } from 'mode-watcher'; import { mode, setMode } from 'mode-watcher';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import { languages } from '$lib/languages'; import { languages } from '$lib/languages';
import { getURLForLanguage } from '$lib/utils'; import { getURLForLanguage } from '$lib/utils';
import { settings } from '$lib/logic/settings'; import { settings } from '$lib/logic/settings';
import { import { createFile, loadFiles, triggerFileInput } from '$lib/logic/file-actions';
createFile,
loadFiles,
pasteSelection,
triggerFileInput,
} from '$lib/logic/file-actions';
import { engine } from '$lib/engine'; import { engine } from '$lib/engine';
import { allHidden } from '$lib/all-hidden'; import { allHidden } from '$lib/all-hidden';
import { selectionSize } from '$lib/selection-helpers'; import { selectionSize } from '$lib/selection-helpers';
// TODO the clipboard and centering on the selection still work on the previous implementation // TODO centering on the selection still works on the previous implementation
import { copied, selection as oldSelection } from '$lib/logic/selection';
import { boundsManager } from '$lib/logic/bounds'; import { boundsManager } from '$lib/logic/bounds';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { allowedPastes } from '$lib/components/file-list/sortable-file-list';
const { const {
distanceUnits, distanceUnits,
@@ -91,22 +83,12 @@
routing, routing,
} = settings; } = settings;
const { files, selection, canUndo, canRedo } = engine; const { files, selection, canUndo, canRedo, canPaste } = engine;
let selectionCount = $derived(selectionSize($selection)); let selectionCount = $derived(selectionSize($selection));
let noFiles = $derived($files.size === 0); let noFiles = $derived($files.size === 0);
// metadata and style can be edited for files and tracks only // metadata and style can be edited for files and tracks only
let filesOrTracksSelected = $derived($selection.type === 'file' || $selection.type === 'track'); 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() { function switchBasemaps() {
[$currentBasemap, $previousBasemap] = [$previousBasemap, $currentBasemap]; [$currentBasemap, $previousBasemap] = [$previousBasemap, $currentBasemap];
@@ -291,29 +273,17 @@
</Menubar.Item> </Menubar.Item>
{#if $treeFileView} {#if $treeFileView}
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item <Menubar.Item onclick={() => engine.copy()} disabled={selectionCount === 0}>
onclick={() => oldSelection.copySelection()}
disabled={selectionCount === 0}
>
<ClipboardCopy size="16" /> <ClipboardCopy size="16" />
{i18n._('menu.copy')} {i18n._('menu.copy')}
<Shortcut key="C" ctrl={true} /> <Shortcut key="C" ctrl={true} />
</Menubar.Item> </Menubar.Item>
<Menubar.Item <Menubar.Item onclick={() => engine.cut()} disabled={selectionCount === 0}>
onclick={() => oldSelection.cutSelection()}
disabled={selectionCount === 0}
>
<Scissors size="16" /> <Scissors size="16" />
{i18n._('menu.cut')} {i18n._('menu.cut')}
<Shortcut key="X" ctrl={true} /> <Shortcut key="X" ctrl={true} />
</Menubar.Item> </Menubar.Item>
<Menubar.Item <Menubar.Item disabled={!$canPaste} onclick={() => engine.paste()}>
disabled={$copied === undefined ||
$copied.length === 0 ||
(selectionLevel !== undefined &&
!allowedPastes[$copied[0].level].includes(selectionLevel))}
onclick={pasteSelection}
>
<ClipboardPaste size="16" /> <ClipboardPaste size="16" />
{i18n._('menu.paste')} {i18n._('menu.paste')}
<Shortcut key="V" ctrl={true} /> <Shortcut key="V" ctrl={true} />
@@ -558,17 +528,17 @@
e.preventDefault(); e.preventDefault();
} else if (e.key === 'c' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'c' && (e.metaKey || e.ctrlKey)) {
if (!targetInput) { if (!targetInput) {
oldSelection.copySelection(); engine.copy();
e.preventDefault(); e.preventDefault();
} }
} else if (e.key === 'x' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'x' && (e.metaKey || e.ctrlKey)) {
if (!targetInput) { if (!targetInput) {
oldSelection.cutSelection(); engine.cut();
e.preventDefault(); e.preventDefault();
} }
} else if (e.key === 'v' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'v' && (e.metaKey || e.ctrlKey)) {
if (!targetInput) { if (!targetInput) {
pasteSelection(); engine.paste();
e.preventDefault(); e.preventDefault();
} }
} else if ((e.key === 's' || e.key == 'S') && (e.metaKey || e.ctrlKey)) { } else if ((e.key === 's' || e.key == 'S') && (e.metaKey || e.ctrlKey)) {
@@ -3,16 +3,12 @@
import * as ContextMenu from '$lib/components/ui/context-menu'; import * as ContextMenu from '$lib/components/ui/context-menu';
import FileListNodeContent from './FileListNodeContent.svelte'; import FileListNodeContent from './FileListNodeContent.svelte';
import { onMount, setContext } from 'svelte'; import { onMount, setContext } from 'svelte';
import { ListLevel } from './file-list';
import { ClipboardPaste, FileStack, Plus } from '@lucide/svelte'; import { ClipboardPaste, FileStack, Plus } from '@lucide/svelte';
import Shortcut from '$lib/components/Shortcut.svelte'; import Shortcut from '$lib/components/Shortcut.svelte';
import { i18n } from '$lib/i18n.svelte'; import { i18n } from '$lib/i18n.svelte';
import { engine } from '$lib/engine'; import { engine } from '$lib/engine';
import { selectedFileIds } from '$lib/selection-helpers'; import { selectedFileIds } from '$lib/selection-helpers';
import { createFile, pasteSelection } from '$lib/logic/file-actions'; import { createFile } 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';
let { let {
orientation, orientation,
@@ -29,7 +25,7 @@
setContext('orientation', orientation); setContext('orientation', orientation);
setContext('recursive', recursive); setContext('recursive', recursive);
const { files, selection } = engine; const { files, selection, canPaste } = engine;
onMount(() => { onMount(() => {
if (orientation === 'horizontal' && $selection.type !== 'file') { if (orientation === 'horizontal' && $selection.type !== 'file') {
@@ -71,12 +67,7 @@
<Shortcut key="A" ctrl={true} /> <Shortcut key="A" ctrl={true} />
</ContextMenu.Item> </ContextMenu.Item>
<ContextMenu.Separator /> <ContextMenu.Separator />
<ContextMenu.Item <ContextMenu.Item disabled={!$canPaste} onclick={() => engine.paste()}>
disabled={$copied === undefined ||
$copied.length === 0 ||
!allowedPastes[$copied[0].level].includes(ListLevel.ROOT)}
onclick={pasteSelection}
>
<ClipboardPaste size="16" /> <ClipboardPaste size="16" />
{i18n._('menu.paste')} {i18n._('menu.paste')}
<Shortcut key="V" ctrl={true} /> <Shortcut key="V" ctrl={true} />
@@ -37,15 +37,12 @@
selectionSize, selectionSize,
type FileTreeNode, type FileTreeNode,
} from '$lib/selection-helpers'; } from '$lib/selection-helpers';
// TODO the clipboard, centering on the selection and the waypoint popup still work on the // TODO centering on the selection and the waypoint popup still work on the previous
// previous implementation // implementation
import { copied, selection as oldSelection } from '$lib/logic/selection';
import { pasteSelection } from '$lib/logic/file-actions';
import { boundsManager } from '$lib/logic/bounds'; import { boundsManager } from '$lib/logic/bounds';
// import { gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers'; // import { gpxLayers } from '$lib/components/map/gpx-layer/gpx-layers';
// import { fileStateCollection } from '$lib/logic/file-state'; // import { fileStateCollection } from '$lib/logic/file-state';
// import { waypointPopup } from '$lib/components/map/gpx-layer/gpx-layer-popup'; // import { waypointPopup } from '$lib/components/map/gpx-layer/gpx-layer-popup';
import { allowedPastes } from './sortable-file-list';
let { let {
fileState, fileState,
@@ -57,7 +54,7 @@
label: string | undefined; label: string | undefined;
} = $props(); } = $props();
const { selection } = engine; const { selection, clipboard, canPaste } = engine;
let orientation = getContext<'vertical' | 'horizontal'>('orientation'); let orientation = getContext<'vertical' | 'horizontal'>('orientation');
let embedding = getContext<boolean>('embedding'); let embedding = getContext<boolean>('embedding');
@@ -107,6 +104,8 @@
); );
let hidden = $derived(isHidden(fileState, elementId(node))); 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));
</script> </script>
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
@@ -150,7 +149,7 @@
<span <span
class="grow text-left truncate ml-1 flex flex-row items-center {hidden class="grow text-left truncate ml-1 flex flex-row items-center {hidden
? 'text-muted-foreground' ? 'text-muted-foreground'
: ''} {/* TODO cut elements: the clipboard works on the previous implementation */ ''}" : ''} {isCut ? 'text-muted-foreground' : ''}"
oncontextmenu={(e) => { oncontextmenu={(e) => {
if (embedding) { if (embedding) {
e.preventDefault(); e.preventDefault();
@@ -278,22 +277,17 @@
<Shortcut key="D" ctrl={true} /> <Shortcut key="D" ctrl={true} />
</ContextMenu.Item> </ContextMenu.Item>
{#if orientation === 'vertical'} {#if orientation === 'vertical'}
<ContextMenu.Item onclick={() => oldSelection.copySelection()}> <ContextMenu.Item onclick={() => engine.copy()}>
<ClipboardCopy size="16" /> <ClipboardCopy size="16" />
{i18n._('menu.copy')} {i18n._('menu.copy')}
<Shortcut key="C" ctrl={true} /> <Shortcut key="C" ctrl={true} />
</ContextMenu.Item> </ContextMenu.Item>
<ContextMenu.Item onclick={() => oldSelection.cutSelection()}> <ContextMenu.Item onclick={() => engine.cut()}>
<Scissors size="16" /> <Scissors size="16" />
{i18n._('menu.cut')} {i18n._('menu.cut')}
<Shortcut key="X" ctrl={true} /> <Shortcut key="X" ctrl={true} />
</ContextMenu.Item> </ContextMenu.Item>
<ContextMenu.Item <ContextMenu.Item disabled={!$canPaste} onclick={() => engine.paste()}>
disabled={$copied === undefined ||
$copied.length === 0 ||
!allowedPastes[$copied[0].level].includes(level)}
onclick={pasteSelection}
>
<ClipboardPaste size="16" /> <ClipboardPaste size="16" />
{i18n._('menu.paste')} {i18n._('menu.paste')}
<Shortcut key="V" ctrl={true} /> <Shortcut key="V" ctrl={true} />
@@ -20,15 +20,6 @@ export const allowedMoves: Record<ListLevel, ListLevel[]> = {
[ListLevel.WAYPOINT]: [ListLevel.WAYPOINTS, ListLevel.WAYPOINT], [ListLevel.WAYPOINT]: [ListLevel.WAYPOINTS, ListLevel.WAYPOINT],
}; };
export const allowedPastes: Record<ListLevel, ListLevel[]> = {
[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<ListLevel | null>(null); export const dragging = writable<ListLevel | null>(null);
/** /**
+34 -1
View File
@@ -4,9 +4,10 @@ import { FileColorAllocator, normalizeColor } from '$lib/file-colors';
import { setHidden, type Visibility } from '$lib/file-visibility'; import { setHidden, type Visibility } from '$lib/file-visibility';
import { selectedElementIds, type FileTreeNode } from '$lib/selection-helpers'; import { selectedElementIds, type FileTreeNode } from '$lib/selection-helpers';
import type { Feature, FeatureCollection, LineString, Point } from 'geojson'; 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 { export type {
Clipboard,
GlobalStatistics, GlobalStatistics,
FileStructure, FileStructure,
FilesUpdate, FilesUpdate,
@@ -199,6 +200,8 @@ class Engine {
private _selection = writable<Selection>({ type: 'empty' }); private _selection = writable<Selection>({ type: 'empty' });
private _canUndo = writable(false); private _canUndo = writable(false);
private _canRedo = writable(false); private _canRedo = writable(false);
private _canPaste = writable(false);
private _clipboard = writable<Clipboard | undefined>(undefined);
private _statistics = writable<SelectionStatistics>(EMPTY_SELECTION_STATISTICS); private _statistics = writable<SelectionStatistics>(EMPTY_SELECTION_STATISTICS);
/** Identifies the statistics currently in the engine's buffers. */ /** Identifies the statistics currently in the engine's buffers. */
private _statisticsVersion = 0; private _statisticsVersion = 0;
@@ -209,6 +212,10 @@ class Engine {
readonly statistics: Readable<SelectionStatistics> = { subscribe: this._statistics.subscribe }; readonly statistics: Readable<SelectionStatistics> = { subscribe: this._statistics.subscribe };
readonly canUndo: Readable<boolean> = { subscribe: this._canUndo.subscribe }; readonly canUndo: Readable<boolean> = { subscribe: this._canUndo.subscribe };
readonly canRedo: Readable<boolean> = { subscribe: this._canRedo.subscribe }; readonly canRedo: Readable<boolean> = { subscribe: this._canRedo.subscribe };
/** What was copied or cut, waiting to be pasted. */
readonly clipboard: Readable<Clipboard | undefined> = { subscribe: this._clipboard.subscribe };
/** Whether the clipboard can be pasted with the current selection. */
readonly canPaste: Readable<boolean> = { subscribe: this._canPaste.subscribe };
/** What is currently selected. */ /** What is currently selected. */
readonly selection: Readable<Selection> = { subscribe: this._selection.subscribe }; readonly selection: Readable<Selection> = { 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 // Edits of the selection
newTrack() { newTrack() {
@@ -408,6 +437,10 @@ class Engine {
const update = wasm.last_update(); const update = wasm.last_update();
this._canUndo.set(wasm.can_undo()); this._canUndo.set(wasm.can_undo());
this._canRedo.set(wasm.can_redo()); this._canRedo.set(wasm.can_redo());
this._canPaste.set(wasm.can_paste());
if (update.clipboardChanged) {
this._clipboard.set(wasm.clipboard());
}
if (update.selectionChanged) { if (update.selectionChanged) {
this._selection.set(wasm.selection()); this._selection.set(wasm.selection());
} }