mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-06 22:04:06 +00:00
progress
This commit is contained in:
@@ -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>,
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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::*;
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user