This commit is contained in:
vcoppe committed 2026-10-14 09:22:07 +02:00
1 parent 41b9116c6a
commit 97d597e139
181 files changed
+1970 -2643

No files matched your search

+469
View File
@@ -0,0 +1,469 @@
use std::rc::Rc;
use uuid::Uuid;
use crate::{
File, FileId, Files, Selection, Track, TrackId, TrackSegment, TrackSegmentId, Waypoint,
WaypointId,
};
/// A track in the clipboard, with the name of the file it comes from (the name of the new file
/// when it is pasted on its own).
#[derive(Debug, Clone)]
pub struct ClipboardTrack {
pub track: Track,
pub file_name: String,
}
/// A segment in the clipboard, with the name of its track, or of its file when the track has
/// none (the name of the new file when it is pasted on its own).
#[derive(Debug, Clone)]
pub struct ClipboardSegment {
pub segment: TrackSegment,
pub name: String,
}
/// The elements that were copied or cut, in the order of the files.
///
/// It holds the elements themselves, not references to them: they are cheap to keep (files are
/// shared, and so are the trackpoints of tracks and segments), and the clipboard stays valid
/// when the elements are changed or deleted.
#[derive(Debug, Clone)]
pub enum ClipboardContent {
Files(Vec<Rc<File>>),
Tracks(Vec<ClipboardTrack>),
Segments(Vec<ClipboardSegment>),
Waypoints(Vec<Waypoint>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ClipboardId(Uuid);
impl Default for ClipboardId {
fn default() -> Self {
Self(Uuid::new_v4())
}
}
/// What was copied or cut, to be pasted, as many times as needed.
///
/// What is pasted is always the elements as they were when they were copied or cut, whatever
/// happened to them since, even if they were deleted. Pasting what was copied makes copies, with
/// new ids. What was cut is moved, with its ids, the first time it is pasted (the elements that
/// are still there are removed): the clipboard is not cut anymore after that, and the next
/// pastes make copies.
#[derive(Debug, Clone)]
pub struct Clipboard {
id: ClipboardId,
pub content: ClipboardContent,
pub cut: bool,
}
/// Two clipboards are the same when they come from the same copy, and are cut the same way.
impl PartialEq for Clipboard {
fn eq(&self, other: &Self) -> bool {
self.id == other.id && self.cut == other.cut
}
}
impl Clipboard {
/// The clipboard for the selected elements, `None` if nothing is selected (or if what is
/// selected does not exist).
pub fn new(
selection: &Selection,
files: &Files,
order: &[FileId],
cut: bool,
) -> Option<Clipboard> {
let content = match selection {
Selection::Empty => return None,
Selection::File { file_ids } => ClipboardContent::Files(
order
.iter()
.filter(|id| file_ids.contains(id))
.filter_map(|id| files.get(id).cloned())
.collect(),
),
Selection::Track { file_id, trk_ids } => {
let file = files.get(file_id)?;
ClipboardContent::Tracks(
file.trk
.iter()
.filter(|trk| trk_ids.contains(&trk.id))
.map(|trk| ClipboardTrack {
track: trk.clone(),
file_name: file.info.name.clone(),
})
.collect(),
)
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
let file = files.get(file_id)?;
let trk = file.trk.iter().find(|trk| trk.id == *trk_id)?;
let name = trk
.info
.name
.clone()
.unwrap_or_else(|| file.info.name.clone());
ClipboardContent::Segments(
trk.trkseg
.iter()
.filter(|seg| trkseg_ids.contains(&seg.id))
.map(|seg| ClipboardSegment {
segment: seg.clone(),
name: name.clone(),
})
.collect(),
)
}
Selection::Waypoints { file_id } => {
ClipboardContent::Waypoints(files.get(file_id)?.wpt.iter().cloned().collect())
}
Selection::Waypoint { file_id, wpt_ids } => ClipboardContent::Waypoints(
files
.get(file_id)?
.wpt
.iter()
.filter(|wpt| wpt_ids.contains(&wpt.id))
.cloned()
.collect(),
),
};
let clipboard = Clipboard {
id: ClipboardId::default(),
content,
cut,
};
(!clipboard.is_empty()).then_some(clipboard)
}
pub fn is_empty(&self) -> bool {
match &self.content {
ClipboardContent::Files(files) => files.is_empty(),
ClipboardContent::Tracks(tracks) => tracks.is_empty(),
ClipboardContent::Segments(segments) => segments.is_empty(),
ClipboardContent::Waypoints(waypoints) => waypoints.is_empty(),
}
}
/// Whether the content can be pasted when `target` is selected: files and tracks can be
/// 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 ClipboardContent as C;
use Selection::*;
matches!(
(&self.content, target),
(C::Files(_), Empty | File { .. })
| (C::Tracks(_), Empty | File { .. } | Track { .. })
| (
C::Segments(_),
Empty | File { .. } | Track { .. } | TrackSegment { .. }
)
| (
C::Waypoints(_),
File { .. } | Waypoints { .. } | Waypoint { .. }
)
)
}
}
/// The ids of the elements of the content, by kind.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClipboardIds {
Files(Vec<FileId>),
Tracks(Vec<TrackId>),
Segments(Vec<TrackSegmentId>),
Waypoints(Vec<WaypointId>),
}
impl ClipboardContent {
pub fn ids(&self) -> ClipboardIds {
match self {
ClipboardContent::Files(files) => {
ClipboardIds::Files(files.iter().map(|file| file.id).collect())
}
ClipboardContent::Tracks(tracks) => {
ClipboardIds::Tracks(tracks.iter().map(|trk| trk.track.id).collect())
}
ClipboardContent::Segments(segments) => {
ClipboardIds::Segments(segments.iter().map(|seg| seg.segment.id).collect())
}
ClipboardContent::Waypoints(waypoints) => {
ClipboardIds::Waypoints(waypoints.iter().map(|wpt| wpt.id).collect())
}
}
}
}
#[cfg(test)]
mod tests {
use crate::{TrackInfo, WaypointChunk};
use super::*;
/// A file with 3 tracks (named "t0", "t1", "t2") of 2 segments and 3 waypoints.
fn file(name: &str) -> File {
let mut file = File::default();
file.info.name = name.to_owned();
for i in 0..3 {
file.trk.push(Track {
info: TrackInfo {
name: (i < 2).then(|| format!("t{i}")),
..Default::default()
},
trkseg: vec![TrackSegment::default(), TrackSegment::default()],
..Default::default()
});
}
file.wpt.push(WaypointChunk {
wpt: vec![Waypoint::default(), Waypoint::default()],
..Default::default()
});
file.wpt.push(WaypointChunk {
wpt: vec![Waypoint::default()],
..Default::default()
});
file
}
fn files_of(files: Vec<File>) -> Files {
files
.into_iter()
.map(|file| (file.id, Rc::new(file)))
.collect()
}
fn order_of(files: &Files) -> Vec<FileId> {
files.keys().copied().collect()
}
fn tracks(file: &File, ids: &[usize]) -> Selection {
Selection::Track {
file_id: file.id,
trk_ids: ids.iter().map(|i| file.trk[*i].id).collect(),
}
}
#[test]
fn test_the_clipboard_holds_what_is_selected() {
let file = file("source");
let (t0, t2) = (file.trk[0].id, file.trk[2].id);
let (s0, s1) = (file.trk[1].trkseg[0].id, file.trk[1].trkseg[1].id);
let wpt: Vec<_> = file.wpt.iter().map(|w| w.id).collect();
let files = files_of(vec![file.clone()]);
// tracks, in the order of the file, with the name of their file
let clipboard =
Clipboard::new(&tracks(&file, &[2, 0]), &files, &order_of(&files), false).unwrap();
assert!(!clipboard.cut);
match &clipboard.content {
ClipboardContent::Tracks(copied) => {
assert_eq!(
copied.iter().map(|c| c.track.id).collect::<Vec<_>>(),
vec![t0, t2]
);
assert!(copied.iter().all(|c| c.file_name == "source"));
}
other => panic!("{other:?}"),
}
// segments, with the name of their track, or else of their file
let segments = |trk: usize, ids: &[TrackSegmentId]| Selection::TrackSegment {
file_id: file.id,
trk_id: file.trk[trk].id,
trkseg_ids: ids.iter().copied().collect(),
};
match Clipboard::new(&segments(1, &[s1, s0]), &files, &order_of(&files), true)
.unwrap()
.content
{
ClipboardContent::Segments(copied) => {
assert_eq!(
copied.iter().map(|c| c.segment.id).collect::<Vec<_>>(),
vec![s0, s1]
);
assert!(copied.iter().all(|c| c.name == "t1"));
}
other => panic!("{other:?}"),
}
let unnamed = file.trk[2].trkseg[0].id;
match Clipboard::new(&segments(2, &[unnamed]), &files, &order_of(&files), true)
.unwrap()
.content
{
ClipboardContent::Segments(copied) => assert_eq!(copied[0].name, "source"),
other => panic!("{other:?}"),
}
// waypoints: some of them, or all of them
let some = Selection::Waypoint {
file_id: file.id,
wpt_ids: [wpt[2], wpt[0]].into(),
};
assert_eq!(
Clipboard::new(&some, &files, &order_of(&files), false)
.unwrap()
.content
.ids(),
ClipboardIds::Waypoints(vec![wpt[0], wpt[2]])
);
assert_eq!(
Clipboard::new(
&Selection::Waypoints { file_id: file.id },
&files,
&order_of(&files),
false
)
.unwrap()
.content
.ids(),
ClipboardIds::Waypoints(wpt)
);
// files
let selection = Selection::File {
file_ids: [file.id].into(),
};
assert_eq!(
Clipboard::new(&selection, &files, &order_of(&files), false)
.unwrap()
.content
.ids(),
ClipboardIds::Files(vec![file.id])
);
}
#[test]
fn test_nothing_to_copy() {
let file = file("f");
let files = files_of(vec![file.clone()]);
assert!(Clipboard::new(&Selection::Empty, &files, &order_of(&files), false).is_none());
// elements that do not exist
let unknown = Selection::Track {
file_id: FileId::default(),
trk_ids: [TrackId::default()].into(),
};
assert!(Clipboard::new(&unknown, &files, &order_of(&files), false).is_none());
let unknown = Selection::Track {
file_id: file.id,
trk_ids: [TrackId::default()].into(),
};
assert!(Clipboard::new(&unknown, &files, &order_of(&files), false).is_none());
assert!(
Clipboard::new(
&Selection::File {
file_ids: [FileId::default()].into()
},
&files,
&order_of(&files),
false
)
.is_none()
);
// a file without waypoints
let empty = files_of(vec![File::default()]);
let id = *empty.keys().next().unwrap();
assert!(
Clipboard::new(
&Selection::Waypoints { file_id: id },
&empty,
&order_of(&empty),
true
)
.is_none()
);
}
#[test]
fn test_two_clipboards_are_the_same_when_they_come_from_the_same_copy() {
let file = file("f");
let files = files_of(vec![file.clone()]);
let selection = tracks(&file, &[0]);
let clipboard = Clipboard::new(&selection, &files, &order_of(&files), false).unwrap();
assert_eq!(clipboard, clipboard.clone());
// the same elements copied again make another clipboard
assert_ne!(
clipboard,
Clipboard::new(&selection, &files, &order_of(&files), false).unwrap()
);
// and it is not the same once it is not cut
let mut copy = clipboard.clone();
copy.cut = true;
assert_ne!(clipboard, copy);
}
#[test]
fn test_the_clipboard_survives_what_happens_to_the_files() {
let file = file("f");
let mut files = files_of(vec![file.clone()]);
let clipboard =
Clipboard::new(&tracks(&file, &[0, 1]), &files, &order_of(&files), true).unwrap();
files.clear();
assert!(!clipboard.is_empty());
assert_eq!(
clipboard.content.ids(),
ClipboardIds::Tracks(vec![file.trk[0].id, file.trk[1].id])
);
}
#[test]
fn test_where_things_can_be_pasted() {
let file = file("f");
let files = files_of(vec![file.clone()]);
let selection_of = |selection: Selection| {
Clipboard::new(&selection, &files, &order_of(&files), false).unwrap()
};
let file_selection = Selection::File {
file_ids: [file.id].into(),
};
let track = tracks(&file, &[0]);
let segment = Selection::TrackSegment {
file_id: file.id,
trk_id: file.trk[0].id,
trkseg_ids: [file.trk[0].trkseg[0].id].into(),
};
let waypoints = Selection::Waypoints { file_id: file.id };
let waypoint = Selection::Waypoint {
file_id: file.id,
wpt_ids: [file.wpt[0].id].into(),
};
let targets = [
Selection::Empty,
file_selection.clone(),
track.clone(),
segment.clone(),
waypoints.clone(),
waypoint.clone(),
];
let expected = [
// nothing, file, track, segment, waypoints, waypoint
(file_selection, [true, true, false, false, false, false]),
(track, [true, true, true, false, false, false]),
(segment, [true, true, true, true, false, false]),
(waypoints, [false, true, false, false, true, true]),
(waypoint, [false, true, false, false, true, true]),
];
for (copied, allowed) in expected {
let clipboard = selection_of(copied.clone());
for (target, allowed) in targets.iter().zip(allowed) {
assert_eq!(
clipboard.can_paste(target),
allowed,
"{copied:?} on {target:?}"
);
}
}
}
}
+435
View File
@@ -0,0 +1,435 @@
use std::{
collections::{HashMap, HashSet},
rc::Rc,
};
use crate::{CommandError, File, FileId};
#[derive(Debug, Default)]
pub struct History {
entries: Vec<Files>,
index: Option<usize>,
/// Whether the first entry is the state the history starts from, which is not undone: files
/// restored from the storage.
floor: bool,
}
impl History {
/// A history whose first entry is `entry`, from which nothing can be undone.
pub fn restored(entry: Files) -> Self {
Self {
entries: vec![entry],
index: Some(0),
floor: true,
}
}
pub fn current(&self) -> Option<&Files> {
self.index.map_or_default(|i| self.get(i))
}
/// Applies `f` to a copy of the current entry and makes the copy the new current entry.
///
/// Nothing is pushed, and `None` is returned, if `f` fails (the error is returned) or if it
/// leaves every file as it was: a step that undoes nothing is not worth a place in the
/// history. Files are told apart by their identity, not their content.
pub fn create_and_push_next<F>(&mut self, f: F) -> Result<Option<Diff>, CommandError>
where
F: FnOnce(&mut Files) -> Result<(), CommandError>,
{
let mut next = self.current().map_or_default(|c| c.clone());
f(&mut next)?;
if self.is_current(&next) {
return Ok(None);
}
Ok(self.record_diff(|history| history.push(next)))
}
/// Whether `entry` holds exactly the files of the current entry.
fn is_current(&self, entry: &Files) -> bool {
let current = self.current();
let len = current.map_or(0, |current| current.len());
entry.len() == len
&& current.is_none_or(|current| {
entry
.iter()
.all(|(id, file)| current.get(id).is_some_and(|c| Rc::ptr_eq(c, file)))
})
}
pub fn undo(&mut self) -> Option<Diff> {
self.record_diff(|history| {
if let Some(i) = history.index {
if i == 0 {
if !history.floor {
history.index = None;
}
} else {
history.index = Some(i - 1);
}
}
})
}
pub fn redo(&mut self) -> Option<Diff> {
self.record_diff(|history| match history.index {
Some(i) => {
if i + 1 < history.entries.len() {
history.index = Some(i + 1);
}
}
None => {
if !history.entries.is_empty() {
history.index = Some(0);
}
}
})
}
pub fn can_undo(&self) -> bool {
self.index.is_some_and(|i| i > 0 || !self.floor)
}
pub fn can_redo(&self) -> bool {
match self.index {
Some(i) => i + 1 < self.entries.len(),
None => !self.entries.is_empty(),
}
}
fn get(&self, index: usize) -> Option<&Files> {
self.entries.get(index)
}
fn push(&mut self, entry: Files) {
if let Some(i) = self.index {
self.entries.truncate(i + 1);
}
self.entries.push(entry);
self.index = Some(self.entries.len() - 1);
}
fn record_diff<F>(&mut self, f: F) -> Option<Diff>
where
F: FnOnce(&mut Self),
{
let prev = self.index;
f(self);
let cur = self.index;
if prev == cur {
return None;
}
Some(Self::diff_between(
prev.map_or_default(|i| self.get(i)),
cur.map_or_default(|i| self.get(i)),
))
}
/// Which files differ between two states.
pub fn diff_between(prev: Option<&Files>, cur: Option<&Files>) -> Diff {
let prev_ids: HashSet<FileId> = prev.map_or_default(|e| e.keys().copied().collect());
let cur_ids: HashSet<FileId> = cur.map_or_default(|e| e.keys().copied().collect());
let mut modified = vec![];
for id in prev_ids.intersection(&cur_ids) {
let before = prev.map_or_default(|e| e.get(id));
let after = cur.map_or_default(|e| e.get(id));
if !before.zip(after).is_some_and(|(b, a)| Rc::ptr_eq(b, a)) {
modified.push(*id);
}
}
Diff {
added: cur_ids.difference(&prev_ids).copied().collect(),
removed: prev_ids.difference(&cur_ids).copied().collect(),
modified,
}
}
}
pub type Files = HashMap<FileId, Rc<File>>;
/// Which files were added, removed or modified by a change of the current history entry.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Diff {
pub added: Vec<FileId>,
pub removed: Vec<FileId>,
pub modified: Vec<FileId>,
}
#[cfg(test)]
mod tests {
use super::*;
impl History {
/// A step that is expected not to fail.
fn push_ok(
&mut self,
f: impl FnOnce(&mut Files) -> Result<(), CommandError>,
) -> Option<Diff> {
self.create_and_push_next(f).unwrap()
}
}
#[test]
fn test_new_file() {
let mut history = History::default();
assert!(!history.can_undo());
assert!(!history.can_redo());
assert!(history.current().is_none());
let diff = history.push_ok(|e| {
let file = Rc::new(File::default());
e.insert(file.id, file);
Ok(())
});
assert!(history.can_undo());
assert!(!history.can_redo());
assert!(history.current().is_some());
assert!(diff.is_some());
let diff = diff.unwrap();
assert_eq!(diff.added.len(), 1);
assert!(diff.removed.is_empty());
assert!(diff.modified.is_empty());
}
fn add_file(history: &mut History) -> FileId {
let file = Rc::new(File::default());
let id = file.id;
history
.push_ok(|e| {
e.insert(id, file);
Ok(())
})
.unwrap();
id
}
#[test]
fn test_failed_command_pushes_nothing() {
let mut history = History::default();
let diff = history.create_and_push_next(|e| {
e.insert(FileId::default(), Rc::new(File::default()));
Err(CommandError::InvalidData("nope".to_string()))
});
assert_eq!(diff, Err(CommandError::InvalidData("nope".to_string())));
assert!(!history.can_undo());
assert!(history.current().is_none());
}
#[test]
fn test_undo_redo() {
let mut history = History::default();
assert!(history.undo().is_none());
assert!(history.redo().is_none());
let id = add_file(&mut history);
let diff = history.undo().unwrap();
assert_eq!(diff.removed, vec![id]);
assert!(diff.added.is_empty());
assert!(history.current().is_none());
assert!(!history.can_undo());
assert!(history.can_redo());
assert!(history.undo().is_none());
let diff = history.redo().unwrap();
assert_eq!(diff.added, vec![id]);
assert!(history.current().unwrap().contains_key(&id));
assert!(history.can_undo());
assert!(!history.can_redo());
assert!(history.redo().is_none());
}
#[test]
fn test_modified_and_removed() {
let mut history = History::default();
let id = add_file(&mut history);
let diff = history
.push_ok(|e| {
let mut file = (**e.get(&id).unwrap()).clone();
file.info.name = "renamed".to_string();
e.insert(id, Rc::new(file));
Ok(())
})
.unwrap();
assert_eq!(diff.modified, vec![id]);
assert!(diff.added.is_empty() && diff.removed.is_empty());
// a step that changes nothing is not a step
assert!(history.push_ok(|_| Ok(())).is_none());
assert!(
history
.push_ok(|e| {
let file = e[&id].clone();
e.insert(id, file);
Ok(())
})
.is_none()
);
let diff = history
.push_ok(|e| {
e.remove(&id);
Ok(())
})
.unwrap();
assert_eq!(diff.removed, vec![id]);
assert!(history.current().unwrap().is_empty());
// going back restores the renamed file
history.undo();
assert_eq!(history.current().unwrap()[&id].info.name, "renamed");
}
#[test]
fn test_a_file_is_modified_when_it_is_not_the_same_one_anymore() {
let mut history = History::default();
let id = add_file(&mut history);
// a copy, even if it has the same content, is another file for the history: files are
// told apart by their identity, comparing them would read all of them
let diff = history
.push_ok(|e| {
let copy = (**e.get(&id).unwrap()).clone();
e.insert(id, Rc::new(copy));
Ok(())
})
.unwrap();
assert_eq!(diff.modified, vec![id]);
// and the files that were not replaced are not
let other = add_file(&mut history);
let diff = history
.push_ok(|e| {
let copy = (**e.get(&other).unwrap()).clone();
e.insert(other, Rc::new(copy));
Ok(())
})
.unwrap();
assert_eq!(diff.modified, vec![other]);
}
#[test]
fn test_push_after_undo_drops_redo_branch() {
let mut history = History::default();
let first = add_file(&mut history);
let second = add_file(&mut history);
history.undo();
assert!(history.can_redo());
assert!(!history.current().unwrap().contains_key(&second));
let third = add_file(&mut history);
assert!(!history.can_redo());
assert!(history.redo().is_none());
let current = history.current().unwrap();
assert!(current.contains_key(&first));
assert!(current.contains_key(&third));
assert!(!current.contains_key(&second));
}
fn restored_entry() -> (Files, FileId) {
let file = Rc::new(File::default());
let id = file.id;
(Files::from([(id, file)]), id)
}
#[test]
fn test_restored_history_starts_from_its_entry() {
let (entry, id) = restored_entry();
let mut history = History::restored(entry);
assert!(history.current().unwrap().contains_key(&id));
// there is nothing to go back to, nor forward to
assert!(!history.can_undo());
assert!(!history.can_redo());
assert!(history.undo().is_none());
assert!(history.redo().is_none());
assert!(history.current().unwrap().contains_key(&id));
}
#[test]
fn test_restored_history_cannot_undo_an_empty_entry_either() {
let mut history = History::restored(Files::new());
assert!(history.current().is_some_and(|files| files.is_empty()));
assert!(!history.can_undo());
assert!(history.undo().is_none());
// the history is not the one of an engine that has done nothing
assert!(history.current().is_some());
}
#[test]
fn test_restored_history_undoes_what_comes_after_it_and_no_further() {
let (entry, restored) = restored_entry();
let mut history = History::restored(entry);
let added = add_file(&mut history);
assert!(history.can_undo());
assert!(!history.can_redo());
assert_eq!(history.current().unwrap().len(), 2);
// back to the restored files
let diff = history.undo().unwrap();
assert_eq!(diff.removed, vec![added]);
assert!(diff.added.is_empty() && diff.modified.is_empty());
assert_eq!(history.current().unwrap().len(), 1);
assert!(history.current().unwrap().contains_key(&restored));
assert!(!history.can_undo());
assert!(history.can_redo());
assert!(history.undo().is_none());
assert!(history.current().unwrap().contains_key(&restored));
let diff = history.redo().unwrap();
assert_eq!(diff.added, vec![added]);
assert!(history.can_undo());
assert!(!history.can_redo());
// and again
assert!(history.undo().is_some());
assert!(history.undo().is_none());
assert!(history.current().unwrap().contains_key(&restored));
}
#[test]
fn test_restored_history_removing_its_files_can_be_undone() {
let (entry, restored) = restored_entry();
let mut history = History::restored(entry);
let diff = history
.push_ok(|e| {
e.remove(&restored);
Ok(())
})
.unwrap();
assert_eq!(diff.removed, vec![restored]);
assert!(history.current().unwrap().is_empty());
let diff = history.undo().unwrap();
assert_eq!(diff.added, vec![restored]);
assert!(history.current().unwrap().contains_key(&restored));
assert!(!history.can_undo());
}
#[test]
fn test_restored_history_push_after_undo_drops_redo_branch() {
let (entry, restored) = restored_entry();
let mut history = History::restored(entry);
add_file(&mut history);
history.undo().unwrap();
assert!(history.can_redo());
let other = add_file(&mut history);
assert!(!history.can_redo());
assert!(history.can_undo());
let diff = history.undo().unwrap();
assert_eq!(diff.removed, vec![other]);
assert!(history.current().unwrap().contains_key(&restored));
assert!(!history.can_undo());
}
}
+17
View File
@@ -0,0 +1,17 @@
mod clipboard;
mod history;
mod order;
mod selection;
pub use clipboard::*;
pub use history::*;
pub use order::*;
pub use selection::*;
pub struct State<'a> {
pub categories: &'a mut crate::TrackpointCategories,
pub files: &'a mut Files,
pub selection: &'a mut Selection,
pub order: &'a mut FileOrder,
pub clipboard: &'a mut Option<Clipboard>,
}
+97
View File
@@ -0,0 +1,97 @@
use std::collections::HashSet;
use crate::{FileId, Files};
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct FileOrder(pub Vec<FileId>);
impl FileOrder {
/// Moves the given files, as a block and in the given order, so that they start at `index`
/// among the other files (clamped to the end). Unknown ids are ignored. Returns whether the
/// order changed.
pub fn move_files(&mut self, file_ids: &[FileId], index: usize) -> bool {
let mut seen = HashSet::new();
let moved: Vec<FileId> = file_ids
.iter()
.copied()
.filter(|id| self.0.contains(id) && seen.insert(*id))
.collect();
let mut order: Vec<FileId> = self
.0
.iter()
.copied()
.filter(|id| !seen.contains(id))
.collect();
let index = index.min(order.len());
order.splice(index..index, moved);
let changed = order != self.0;
self.0 = order;
changed
}
/// Drops the files that do not exist anymore and appends the new ones at the end.
pub fn sync(&mut self, files: &Files) {
self.0.retain(|id| files.contains_key(id));
let known: HashSet<FileId> = self.0.iter().copied().collect();
self.0
.extend(files.keys().filter(|id| !known.contains(id)).copied());
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ids() -> [FileId; 3] {
[FileId::default(), FileId::default(), FileId::default()]
}
#[test]
fn test_move_files_as_block_in_given_order() {
let [a, b, c] = ids();
let mut order = FileOrder(vec![a, b, c]);
assert!(order.move_files(&[c, a], 1));
// index counts among the files that are not moved
assert_eq!(order.0, vec![b, c, a]);
}
#[test]
fn test_move_files_start_and_clamped_end() {
let [a, b, c] = ids();
let mut order = FileOrder(vec![a, b, c]);
assert!(order.move_files(&[c], 0));
assert_eq!(order.0, vec![c, a, b]);
assert!(order.move_files(&[c], 100));
assert_eq!(order.0, vec![a, b, c]);
}
#[test]
fn test_move_files_ignores_unknown_and_duplicate_ids() {
let [a, b, c] = ids();
let mut order = FileOrder(vec![a, b, c]);
assert!(order.move_files(&[FileId::default(), b, b], 0));
assert_eq!(order.0, vec![b, a, c]);
}
#[test]
fn test_move_files_unchanged() {
let [a, b, c] = ids();
let mut order = FileOrder(vec![a, b, c]);
assert!(!order.move_files(&[], 0));
assert!(!order.move_files(&[a, b], 0));
assert!(!order.move_files(&[FileId::default()], 0));
assert_eq!(order.0, vec![a, b, c]);
}
#[test]
fn test_sync_drops_missing_and_appends_new_at_the_end() {
let [a, b, c] = ids();
let mut files = Files::default();
for id in [b, c] {
files.insert(id, Default::default());
}
let mut order = FileOrder(vec![a, b]);
order.sync(&files);
assert_eq!(order.0, vec![b, c]);
}
}
+826
View File
@@ -0,0 +1,826 @@
use std::collections::HashSet;
use crate::{FileId, Files, TrackId, TrackSegmentId, WaypointId};
/// How a new selection combines with the current one.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SelectMode {
/// The new selection replaces the current one.
Replace,
/// The elements are added when they are of the same kind and in the same place as the
/// current selection (see [`Selection::extend`]), otherwise they replace it.
Add,
/// Like `Add`, but the elements already selected are removed instead (see
/// [`Selection::toggle`]).
Toggle,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub enum Selection {
#[default]
Empty,
File {
file_ids: HashSet<FileId>,
},
Track {
file_id: FileId,
trk_ids: HashSet<TrackId>,
},
TrackSegment {
file_id: FileId,
trk_id: TrackId,
trkseg_ids: HashSet<TrackSegmentId>,
},
Waypoints {
file_id: FileId,
},
Waypoint {
file_id: FileId,
wpt_ids: HashSet<WaypointId>,
},
}
/// Where a track segment is: its file, then its track and its own position in it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SegmentLocation {
pub file_id: FileId,
pub trk: usize,
pub seg: usize,
}
impl Selection {
/// Combines `selection` with this one according to `mode`, ignoring the elements of
/// `selection` that do not exist in `files`. Nothing to add or toggle leaves the selection
/// as it is, while replacing it with nothing deselects everything.
pub fn select(&mut self, files: Option<&Files>, mut selection: Selection, mode: SelectMode) {
match files {
Some(files) => selection.retain_existing(files),
None => selection = Selection::Empty,
}
match mode {
SelectMode::Replace => *self = selection,
_ if selection == Selection::Empty => {}
SelectMode::Add => self.extend(selection),
SelectMode::Toggle => self.toggle(selection),
}
}
/// Selects all the elements of the same kind as the selected ones, see
/// [`Selection::all_at_level`]. Does nothing when there is nothing more to select.
pub fn select_all(&mut self, files: &Files, order: &[FileId]) {
if let Some(all) = self.all_at_level(files, order) {
*self = all;
}
}
/// Moves the selection to the next (`down`) or previous element, see
/// [`Selection::neighbour`]. With `add`, the element is added to the selection instead of
/// replacing it.
pub fn select_neighbour(&mut self, files: &Files, order: &[FileId], down: bool, add: bool) {
if let Some(next) = self.neighbour(files, order, down) {
if add {
self.extend(next);
} else {
*self = next;
}
}
}
}
impl Selection {
/// The track segments covered by the selection, in order: the segments of several selected
/// files follow `order` (the files missing from it come last), then the tracks and segments
/// follow the file. Waypoints are not segments: selecting only waypoints covers none.
pub fn segment_locations(&self, files: &Files, order: &[FileId]) -> Vec<SegmentLocation> {
let mut locations = vec![];
let mut add = |file_id: FileId,
trk_filter: &dyn Fn(&crate::Track) -> bool,
seg_filter: &dyn Fn(&crate::TrackSegment) -> bool| {
let Some(file) = files.get(&file_id) else {
return;
};
for (trk, track) in file.trk.iter().enumerate().filter(|(_, t)| trk_filter(t)) {
for (seg, segment) in track.trkseg.iter().enumerate() {
if seg_filter(segment) {
locations.push(SegmentLocation { file_id, trk, seg });
}
}
}
};
match self {
Selection::Empty | Selection::Waypoints { .. } | Selection::Waypoint { .. } => (),
Selection::File { file_ids } => {
let ordered = order
.iter()
.filter(|id| file_ids.contains(id))
.chain(file_ids.iter().filter(|id| !order.contains(id)));
for id in ordered {
add(*id, &|_| true, &|_| true);
}
}
Selection::Track { file_id, trk_ids } => {
add(*file_id, &|trk| trk_ids.contains(&trk.id), &|_| true)
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => add(*file_id, &|trk| trk.id == *trk_id, &|seg| {
trkseg_ids.contains(&seg.id)
}),
}
locations
}
/// Drops what does not exist anymore (e.g. after an undo): files, and the tracks, segments
/// and waypoints of the files that remain. A selection left with nothing becomes empty.
pub fn retain_existing(&mut self, files: &Files) {
let empty = match self {
Selection::Empty => false,
Selection::File { file_ids } => {
file_ids.retain(|id| files.contains_key(id));
file_ids.is_empty()
}
Selection::Track { file_id, trk_ids } => match files.get(file_id) {
Some(file) => {
trk_ids.retain(|id| file.trk.iter().any(|trk| trk.id == *id));
trk_ids.is_empty()
}
None => true,
},
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => match files
.get(file_id)
.and_then(|file| file.trk.iter().find(|trk| trk.id == *trk_id))
{
Some(trk) => {
trkseg_ids.retain(|id| trk.trkseg.iter().any(|seg| seg.id == *id));
trkseg_ids.is_empty()
}
None => true,
},
Selection::Waypoints { file_id } => !files.contains_key(file_id),
Selection::Waypoint { file_id, wpt_ids } => match files.get(file_id) {
Some(file) => {
wpt_ids.retain(|id| file.wpt.iter().any(|wpt| wpt.id == *id));
wpt_ids.is_empty()
}
None => true,
},
};
if empty {
*self = Selection::Empty;
}
}
/// Adds `other` to the selection: the elements are merged when they are of the same kind and
/// in the same place (same file, same track for segments), otherwise `other` replaces the
/// selection.
pub fn extend(&mut self, other: Selection) {
match (&mut *self, other) {
(Selection::File { file_ids }, Selection::File { file_ids: other }) => {
file_ids.extend(other)
}
(
Selection::Track { file_id, trk_ids },
Selection::Track {
file_id: other_file,
trk_ids: other,
},
) if *file_id == other_file => trk_ids.extend(other),
(
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
},
Selection::TrackSegment {
file_id: other_file,
trk_id: other_trk,
trkseg_ids: other,
},
) if *file_id == other_file && *trk_id == other_trk => trkseg_ids.extend(other),
(Selection::Waypoints { file_id }, Selection::Waypoints { file_id: other })
if *file_id == other => {}
(
Selection::Waypoint { file_id, wpt_ids },
Selection::Waypoint {
file_id: other_file,
wpt_ids: other,
},
) if *file_id == other_file => wpt_ids.extend(other),
(selection, other) => *selection = other,
}
}
/// Like [`Selection::extend`], but the elements of `other` that are already selected are
/// removed from the selection instead. A selection left with nothing is empty.
pub fn toggle(&mut self, other: Selection) {
fn toggle_ids<T: std::hash::Hash + Eq>(ids: &mut HashSet<T>, other: HashSet<T>) {
for id in other {
if !ids.remove(&id) {
ids.insert(id);
}
}
}
let empty = match (&mut *self, other) {
(Selection::File { file_ids }, Selection::File { file_ids: other }) => {
toggle_ids(file_ids, other);
file_ids.is_empty()
}
(
Selection::Track { file_id, trk_ids },
Selection::Track {
file_id: other_file,
trk_ids: other,
},
) if *file_id == other_file => {
toggle_ids(trk_ids, other);
trk_ids.is_empty()
}
(
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
},
Selection::TrackSegment {
file_id: other_file,
trk_id: other_trk,
trkseg_ids: other,
},
) if *file_id == other_file && *trk_id == other_trk => {
toggle_ids(trkseg_ids, other);
trkseg_ids.is_empty()
}
(Selection::Waypoints { file_id }, Selection::Waypoints { file_id: other })
if *file_id == other =>
{
true
}
(
Selection::Waypoint { file_id, wpt_ids },
Selection::Waypoint {
file_id: other_file,
wpt_ids: other,
},
) if *file_id == other_file => {
toggle_ids(wpt_ids, other);
wpt_ids.is_empty()
}
(selection, other) => {
*selection = other;
false
}
};
if empty {
*self = Selection::Empty;
}
}
}
/// Position of the first and last of the `selected` ids among the `all` ids.
fn selected_range<T: std::hash::Hash + Eq>(
all: impl Iterator<Item = T>,
selected: &HashSet<T>,
) -> Option<(usize, usize)> {
let mut range: Option<(usize, usize)> = None;
for (i, id) in all.enumerate() {
if selected.contains(&id) {
range = Some(range.map_or((i, i), |(first, _)| (first, i)));
}
}
range
}
/// The id before or after the selected ones among `ids`, without wrapping around.
fn neighbour<T: Copy + PartialEq + std::hash::Hash + Eq>(
ids: &[T],
selected: &HashSet<T>,
down: bool,
) -> Option<T> {
let (first, last) = selected_range(ids.iter().copied(), selected)?;
if down {
ids.get(last + 1).copied()
} else {
first.checked_sub(1).map(|i| ids[i])
}
}
impl Selection {
/// The files holding the selected elements, as a selection of files.
pub fn to_files(&self) -> Selection {
match self {
Selection::Empty => Selection::Empty,
Selection::File { .. } => self.clone(),
Selection::Track { file_id, .. }
| Selection::TrackSegment { file_id, .. }
| Selection::Waypoints { file_id }
| Selection::Waypoint { file_id, .. } => Selection::File {
file_ids: [*file_id].into(),
},
}
}
/// Selects all the elements of the same kind as the selected ones, in the same place: all
/// the files, all the tracks of the file, all the segments of the track, all the waypoints
/// of the file. All the files when nothing is selected. `None` when there is nothing to
/// select (the waypoints node, or a file that does not exist).
pub fn all_at_level(&self, files: &Files, order: &[FileId]) -> Option<Selection> {
match self {
Selection::Empty | Selection::File { .. } => Some(Selection::File {
file_ids: order.iter().copied().collect(),
}),
Selection::Track { file_id, .. } => Some(Selection::Track {
file_id: *file_id,
trk_ids: files.get(file_id)?.trk.iter().map(|trk| trk.id).collect(),
}),
Selection::TrackSegment {
file_id, trk_id, ..
} => {
let trk = files
.get(file_id)?
.trk
.iter()
.find(|trk| trk.id == *trk_id)?;
Some(Selection::TrackSegment {
file_id: *file_id,
trk_id: *trk_id,
trkseg_ids: trk.trkseg.iter().map(|seg| seg.id).collect(),
})
}
Selection::Waypoints { .. } => None,
Selection::Waypoint { file_id, .. } => Some(Selection::Waypoint {
file_id: *file_id,
wpt_ids: files.get(file_id)?.wpt.iter().map(|wpt| wpt.id).collect(),
}),
}
}
/// The element next to the selected ones, as a selection of that one element: the next one
/// (`down`) or the previous one among the elements of the same kind, in the same place.
/// The tracks, segments and waypoints stop at their ends, the files wrap around and skip
/// the ones that are already selected. `None` when there is no such element.
pub fn neighbour(&self, files: &Files, order: &[FileId], down: bool) -> Option<Selection> {
match self {
Selection::Empty | Selection::Waypoints { .. } => None,
Selection::File { file_ids } => {
let (first, last) = selected_range(order.iter(), &file_ids.iter().collect())?;
let limit = if down { last } else { first };
let n = order.len();
let mut i = limit;
loop {
i = if down { (i + 1) % n } else { (i + n - 1) % n };
if i == limit {
return None;
}
if !file_ids.contains(&order[i]) {
return Some(Selection::File {
file_ids: [order[i]].into(),
});
}
}
}
Selection::Track { file_id, trk_ids } => {
let ids: Vec<TrackId> = files.get(file_id)?.trk.iter().map(|t| t.id).collect();
Some(Selection::Track {
file_id: *file_id,
trk_ids: [neighbour(&ids, trk_ids, down)?].into(),
})
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
let trk = files
.get(file_id)?
.trk
.iter()
.find(|trk| trk.id == *trk_id)?;
let ids: Vec<TrackSegmentId> = trk.trkseg.iter().map(|seg| seg.id).collect();
Some(Selection::TrackSegment {
file_id: *file_id,
trk_id: *trk_id,
trkseg_ids: [neighbour(&ids, trkseg_ids, down)?].into(),
})
}
Selection::Waypoint { file_id, wpt_ids } => {
let ids: Vec<WaypointId> =
files.get(file_id)?.wpt.iter().map(|wpt| wpt.id).collect();
Some(Selection::Waypoint {
file_id: *file_id,
wpt_ids: [neighbour(&ids, wpt_ids, down)?].into(),
})
}
}
}
}
#[cfg(test)]
mod tests {
use std::rc::Rc;
use crate::{File, Track, TrackSegment, Waypoint, WaypointChunk};
use super::*;
#[test]
fn test_retain_existing() {
let file = File::default();
let (kept, gone) = (file.id, FileId::default());
let mut files = Files::default();
files.insert(kept, Rc::new(file));
let mut selection = Selection::File {
file_ids: [kept, gone].into(),
};
selection.retain_existing(&files);
assert!(
matches!(&selection, Selection::File { file_ids } if file_ids.len() == 1 && file_ids.contains(&kept))
);
let mut selection = Selection::File {
file_ids: [gone].into(),
};
selection.retain_existing(&files);
assert!(matches!(selection, Selection::Empty));
let mut selection = Selection::Waypoints { file_id: kept };
selection.retain_existing(&files);
assert!(matches!(selection, Selection::Waypoints { .. }));
let mut selection = Selection::Waypoints { file_id: gone };
selection.retain_existing(&files);
assert!(matches!(selection, Selection::Empty));
}
#[test]
fn test_retain_existing_elements() {
let mut file = File::default();
file.trk.push(Track {
trkseg: vec![TrackSegment::default()],
..Default::default()
});
file.wpt.push(WaypointChunk {
wpt: vec![Waypoint::default()],
..Default::default()
});
let (file_id, trk_id) = (file.id, file.trk[0].id);
let (seg_id, wpt_id) = (file.trk[0].trkseg[0].id, file.wpt[0].id);
let mut files = Files::default();
files.insert(file_id, Rc::new(file));
let mut selection = Selection::Track {
file_id,
trk_ids: [trk_id, TrackId::default()].into(),
};
selection.retain_existing(&files);
assert_eq!(
selection,
Selection::Track {
file_id,
trk_ids: [trk_id].into()
}
);
let mut selection = Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids: [seg_id, TrackSegmentId::default()].into(),
};
selection.retain_existing(&files);
assert!(
matches!(&selection, Selection::TrackSegment { trkseg_ids, .. } if trkseg_ids.len() == 1)
);
// a segment under the wrong track
let mut selection = Selection::TrackSegment {
file_id,
trk_id: TrackId::default(),
trkseg_ids: [seg_id].into(),
};
selection.retain_existing(&files);
assert_eq!(selection, Selection::Empty);
let mut selection = Selection::Waypoint {
file_id,
wpt_ids: [wpt_id, WaypointId::default()].into(),
};
selection.retain_existing(&files);
assert!(matches!(&selection, Selection::Waypoint { wpt_ids, .. } if wpt_ids.len() == 1));
let mut selection = Selection::Waypoint {
file_id,
wpt_ids: [WaypointId::default()].into(),
};
selection.retain_existing(&files);
assert_eq!(selection, Selection::Empty);
}
#[test]
fn test_extend() {
let file_id = FileId::default();
let (a, b) = (TrackId::default(), TrackId::default());
let tracks = |ids: &[TrackId]| Selection::Track {
file_id,
trk_ids: ids.iter().copied().collect(),
};
// same kind and place: merged
let mut selection = tracks(&[a]);
selection.extend(tracks(&[b]));
assert_eq!(selection, tracks(&[a, b]));
// another file, or another kind: replaced
let mut selection = tracks(&[a]);
let other = Selection::Track {
file_id: FileId::default(),
trk_ids: [b].into(),
};
selection.extend(other.clone());
assert_eq!(selection, other);
selection.extend(Selection::Waypoints { file_id });
assert_eq!(selection, Selection::Waypoints { file_id });
// segments of another track: replaced
let seg = TrackSegmentId::default();
let segments = |trk_id| Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids: [seg].into(),
};
let mut selection = segments(a);
selection.extend(segments(b));
assert_eq!(selection, segments(b));
}
#[test]
fn test_toggle() {
let file_id = FileId::default();
let (a, b) = (TrackId::default(), TrackId::default());
let tracks = |ids: &[TrackId]| Selection::Track {
file_id,
trk_ids: ids.iter().copied().collect(),
};
// same kind and place: toggled one by one
let mut selection = tracks(&[a]);
selection.toggle(tracks(&[b]));
assert_eq!(selection, tracks(&[a, b]));
selection.toggle(tracks(&[a]));
assert_eq!(selection, tracks(&[b]));
selection.toggle(tracks(&[a, b]));
assert_eq!(selection, tracks(&[a]));
// nothing left: empty
selection.toggle(tracks(&[a]));
assert_eq!(selection, Selection::Empty);
// another file, or another kind: replaced
let mut selection = tracks(&[a]);
selection.toggle(Selection::Waypoints { file_id });
assert_eq!(selection, Selection::Waypoints { file_id });
// the waypoints node toggles itself off
selection.toggle(Selection::Waypoints { file_id });
assert_eq!(selection, Selection::Empty);
// files
let (f1, f2) = (FileId::default(), FileId::default());
let files = |ids: &[FileId]| Selection::File {
file_ids: ids.iter().copied().collect(),
};
let mut selection = files(&[f1]);
selection.toggle(files(&[f2]));
assert_eq!(selection, files(&[f1, f2]));
selection.toggle(files(&[f1]));
assert_eq!(selection, files(&[f2]));
}
/// A file with 3 tracks of 2 segments each and 3 waypoints, with the ids of everything.
struct Tree {
files: Files,
order: Vec<FileId>,
file: FileId,
tracks: Vec<TrackId>,
segments: Vec<Vec<TrackSegmentId>>,
waypoints: Vec<WaypointId>,
}
fn tree() -> Tree {
let mut file = File::default();
for _ in 0..3 {
file.trk.push(Track {
trkseg: vec![TrackSegment::default(), TrackSegment::default()],
..Default::default()
});
}
file.wpt.push(WaypointChunk {
wpt: vec![Waypoint::default(), Waypoint::default()],
..Default::default()
});
file.wpt.push(WaypointChunk {
wpt: vec![Waypoint::default()],
..Default::default()
});
Tree {
file: file.id,
tracks: file.trk.iter().map(|t| t.id).collect(),
segments: file
.trk
.iter()
.map(|t| t.trkseg.iter().map(|s| s.id).collect())
.collect(),
waypoints: file.wpt.iter().map(|w| w.id).collect(),
order: vec![file.id],
files: Files::from([(file.id, Rc::new(file))]),
}
}
#[test]
fn test_all_at_level() {
let t = tree();
let other = FileId::default();
let order = [other, t.file];
let all = |selection: &Selection| selection.all_at_level(&t.files, &order);
// the files, in the order
let all_files = Selection::File {
file_ids: [other, t.file].into(),
};
assert_eq!(all(&Selection::Empty), Some(all_files.clone()));
assert_eq!(
all(&Selection::File {
file_ids: [t.file].into()
}),
Some(all_files)
);
// the siblings of the selected elements
assert_eq!(
all(&Selection::Track {
file_id: t.file,
trk_ids: [t.tracks[1]].into()
}),
Some(Selection::Track {
file_id: t.file,
trk_ids: t.tracks.iter().copied().collect()
})
);
assert_eq!(
all(&Selection::TrackSegment {
file_id: t.file,
trk_id: t.tracks[2],
trkseg_ids: [t.segments[2][0]].into()
}),
Some(Selection::TrackSegment {
file_id: t.file,
trk_id: t.tracks[2],
trkseg_ids: t.segments[2].iter().copied().collect()
})
);
assert_eq!(
all(&Selection::Waypoint {
file_id: t.file,
wpt_ids: [t.waypoints[0]].into()
}),
Some(Selection::Waypoint {
file_id: t.file,
wpt_ids: t.waypoints.iter().copied().collect()
})
);
// nothing to select
assert_eq!(all(&Selection::Waypoints { file_id: t.file }), None);
let unknown = FileId::default();
assert_eq!(
all(&Selection::Track {
file_id: unknown,
trk_ids: HashSet::new()
}),
None
);
}
#[test]
fn test_neighbour_of_tracks_stops_at_the_ends() {
let t = tree();
let tracks = |ids: &[TrackId]| Selection::Track {
file_id: t.file,
trk_ids: ids.iter().copied().collect(),
};
let next = |selection: &Selection, down| selection.neighbour(&t.files, &t.order, down);
assert_eq!(
next(&tracks(&[t.tracks[1]]), true),
Some(tracks(&[t.tracks[2]]))
);
assert_eq!(
next(&tracks(&[t.tracks[1]]), false),
Some(tracks(&[t.tracks[0]]))
);
assert_eq!(next(&tracks(&[t.tracks[2]]), true), None);
assert_eq!(next(&tracks(&[t.tracks[0]]), false), None);
// from the end of the selected range, whatever its holes
let range = tracks(&[t.tracks[0], t.tracks[1]]);
assert_eq!(next(&range, true), Some(tracks(&[t.tracks[2]])));
assert_eq!(next(&range, false), None);
let holes = tracks(&[t.tracks[0], t.tracks[2]]);
assert_eq!(next(&holes, true), None);
assert_eq!(next(&holes, false), None);
}
#[test]
fn test_neighbour_of_segments_and_waypoints() {
let t = tree();
let segments = |trk: usize, seg: usize| Selection::TrackSegment {
file_id: t.file,
trk_id: t.tracks[trk],
trkseg_ids: [t.segments[trk][seg]].into(),
};
let next = |selection: &Selection, down| selection.neighbour(&t.files, &t.order, down);
// the segments stay in their track
assert_eq!(next(&segments(1, 0), true), Some(segments(1, 1)));
assert_eq!(next(&segments(1, 1), true), None);
assert_eq!(next(&segments(1, 1), false), Some(segments(1, 0)));
assert_eq!(next(&segments(1, 0), false), None);
// the waypoints are in the order of the file, across its chunks
let waypoint = |i: usize| Selection::Waypoint {
file_id: t.file,
wpt_ids: [t.waypoints[i]].into(),
};
assert_eq!(next(&waypoint(1), true), Some(waypoint(2)));
assert_eq!(next(&waypoint(2), true), None);
assert_eq!(next(&waypoint(0), false), None);
assert_eq!(next(&waypoint(2), false), Some(waypoint(1)));
assert_eq!(next(&Selection::Waypoints { file_id: t.file }, true), None);
assert_eq!(next(&Selection::Empty, true), None);
}
#[test]
fn test_neighbour_of_files_wraps_around_and_skips_the_selected() {
let files = Files::default();
let [a, b, c] = [FileId::default(), FileId::default(), FileId::default()];
let order = [a, b, c];
let select = |ids: &[FileId]| Selection::File {
file_ids: ids.iter().copied().collect(),
};
let next = |ids: &[FileId], down| select(ids).neighbour(&files, &order, down);
assert_eq!(next(&[a], true), Some(select(&[b])));
assert_eq!(next(&[c], true), Some(select(&[a])));
assert_eq!(next(&[a], false), Some(select(&[c])));
assert_eq!(next(&[b], false), Some(select(&[a])));
// from the end of the selection, skipping the files already selected
assert_eq!(next(&[a, b], true), Some(select(&[c])));
assert_eq!(next(&[a, b], false), Some(select(&[c])));
assert_eq!(next(&[c, a], true), Some(select(&[b])));
// everything is selected
assert_eq!(next(&[a, b, c], true), None);
// none of them is in the order
assert_eq!(next(&[FileId::default()], true), None);
}
#[test]
fn test_to_files() {
let (file_id, trk_id) = (FileId::default(), TrackId::default());
let file = Selection::File {
file_ids: [file_id].into(),
};
assert_eq!(Selection::Empty.to_files(), Selection::Empty);
assert_eq!(file.to_files(), file);
assert_eq!(
Selection::Track {
file_id,
trk_ids: [trk_id].into()
}
.to_files(),
file
);
assert_eq!(
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids: HashSet::new()
}
.to_files(),
file
);
assert_eq!(Selection::Waypoints { file_id }.to_files(), file);
assert_eq!(
Selection::Waypoint {
file_id,
wpt_ids: HashSet::new()
}
.to_files(),
file
);
let several = Selection::File {
file_ids: [file_id, FileId::default()].into(),
};
assert_eq!(several.to_files(), several);
}
}