This commit is contained in:
vcoppe
2026-10-04 14:49:53 +02:00
parent 1d534881f8
commit 55f1d275b7
8 changed files with 758 additions and 154 deletions
+10
View File
@@ -16,6 +16,16 @@ pub enum Action<'a> {
selection: Selection,
mode: SelectMode,
},
/// Selects all the elements of the same kind as the selected ones, in the same place (see
/// [`Selection::all_at_level`]). Does nothing when there is nothing more to select.
SelectAll,
/// Moves the selection to the next (`down`) or previous element of the same kind, like the
/// arrow keys do in the file list (see [`Selection::neighbour`]). With `add`, the element is
/// added to the selection (shift + arrow), otherwise it replaces it.
ArrowSelect {
down: bool,
add: bool,
},
/// Moves the files to `index` among the other files, in the given order. Not undoable.
Reorder {
file_ids: Vec<FileId>,
+67 -10
View File
@@ -4,12 +4,22 @@ use crate::{
Apply, CommandError, FileId, Selection, StackEntry, State, Waypoint, edit_waypoint_chunks,
};
/// Deletes the selected elements. With `whole_files`, the files holding the selected elements
/// are deleted instead, even if only a track or a waypoint is selected.
#[derive(Debug)]
pub struct Delete;
pub struct Delete {
pub whole_files: bool,
}
impl Apply for Delete {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let next = match &*state.selection {
let files = state.selection.to_files();
let selection = if self.whole_files {
&files
} else {
&*state.selection
};
let next = match selection {
Selection::File { file_ids } => {
state.files.retain(|id, _| !file_ids.contains(id));
state.order.0.retain(|id| !file_ids.contains(id));
@@ -112,7 +122,7 @@ mod tests {
fn test_delete_nothing_selected() {
let mut fx = Fixture::default();
assert_eq!(
Delete.apply(&mut fx.state()),
Delete { whole_files: false }.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
@@ -125,7 +135,9 @@ mod tests {
fx.selection = Selection::File {
file_ids: HashSet::from([id]),
};
Delete.apply(&mut fx.state()).unwrap();
Delete { whole_files: false }
.apply(&mut fx.state())
.unwrap();
assert_eq!(fx.order.0, vec![keep]);
assert!(fx.files.contains_key(&keep) && !fx.files.contains_key(&id));
assert!(matches!(fx.selection, Selection::Empty));
@@ -140,7 +152,9 @@ mod tests {
file_id: id,
trk_ids: HashSet::from([trk_id]),
};
Delete.apply(&mut fx.state()).unwrap();
Delete { whole_files: false }
.apply(&mut fx.state())
.unwrap();
let file = &fx.files[&id];
assert_eq!(file.trk.len(), before - 1);
assert!(file.trk.iter().all(|t| t.id != trk_id));
@@ -157,7 +171,9 @@ mod tests {
trk_id,
trkseg_ids: HashSet::from([seg_id]),
};
Delete.apply(&mut fx.state()).unwrap();
Delete { whole_files: false }
.apply(&mut fx.state())
.unwrap();
let trk = &fx.files[&id].trk[0];
assert_eq!(trk.trkseg.len(), before - 1);
assert!(trk.trkseg.iter().all(|s| s.id != seg_id));
@@ -187,7 +203,9 @@ mod tests {
wpt_ids: HashSet::from([ids[1]]),
};
let rev = fx.files[&id].wpt_rev_id;
Delete.apply(&mut fx.state()).unwrap();
Delete { whole_files: false }
.apply(&mut fx.state())
.unwrap();
assert_ne!(fx.files[&id].wpt_rev_id, rev);
let left: Vec<_> = fx.files[&id]
.wpt
@@ -203,11 +221,13 @@ mod tests {
let (mut fx, id) = loaded();
with_waypoints(&mut fx, id, 3);
fx.selection = Selection::Waypoints { file_id: id };
Delete.apply(&mut fx.state()).unwrap();
Delete { whole_files: false }
.apply(&mut fx.state())
.unwrap();
assert!(fx.files[&id].wpt.is_empty());
fx.selection = Selection::Waypoints { file_id: id };
assert_eq!(
Delete.apply(&mut fx.state()),
Delete { whole_files: false }.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
@@ -220,7 +240,44 @@ mod tests {
trk_ids: HashSet::from([Default::default()]),
};
assert_eq!(
Delete.apply(&mut fx.state()),
Delete { whole_files: false }.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
#[test]
fn test_delete_whole_files_of_the_selected_elements() {
for selection in [
|id: FileId, fx: &Fixture| Selection::Track {
file_id: id,
trk_ids: HashSet::from([fx.files[&id].trk[0].id]),
},
|id: FileId, fx: &Fixture| Selection::TrackSegment {
file_id: id,
trk_id: fx.files[&id].trk[0].id,
trkseg_ids: HashSet::from([fx.files[&id].trk[0].trkseg[0].id]),
},
|id: FileId, _: &Fixture| Selection::Waypoints { file_id: id },
|id: FileId, _: &Fixture| Selection::File {
file_ids: HashSet::from([id]),
},
] {
let (mut fx, id) = loaded();
crate::New { name: "keep" }.apply(&mut fx.state()).unwrap();
let keep = fx.order.0[1];
fx.selection = selection(id, &fx);
Delete { whole_files: true }.apply(&mut fx.state()).unwrap();
assert_eq!(fx.order.0, vec![keep]);
assert!(!fx.files.contains_key(&id));
assert!(matches!(fx.selection, Selection::Empty));
}
}
#[test]
fn test_delete_whole_files_nothing_selected() {
let mut fx = Fixture::default();
assert_eq!(
Delete { whole_files: true }.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
+174
View File
@@ -41,6 +41,16 @@ impl Engine {
Some(self.statistics_cache.file_global(file))
}
/// Whether there is something to undo.
pub fn can_undo(&self) -> bool {
self.stack.can_undo()
}
/// Whether there is something to redo.
pub fn can_redo(&self) -> bool {
self.stack.can_redo()
}
/// Which files the last action added, removed or modified.
pub fn last_diff(&self) -> Option<&Diff> {
self.diff.as_ref()
@@ -98,6 +108,26 @@ impl Engine {
}
None
}
Action::SelectAll => {
if let Some(files) = self.stack.current()
&& let Some(all) = self.selection.all_at_level(files, &self.order.0)
{
self.selection = all;
}
None
}
Action::ArrowSelect { down, add } => {
if let Some(files) = self.stack.current()
&& let Some(next) = self.selection.neighbour(files, &self.order.0, down)
{
if add {
self.selection.extend(next);
} else {
self.selection = next;
}
}
None
}
Action::Reorder { file_ids, index } => {
self.order.move_files(&file_ids, index);
None
@@ -154,6 +184,8 @@ impl Engine {
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use crate::{Load, Metadata, New, NewTrack, Style, TrackId, TrackSegmentId};
use super::*;
@@ -635,4 +667,146 @@ mod tests {
engine.execute(Action::Undo);
assert_eq!(engine.selection(), &Selection::Empty);
}
#[test]
fn test_can_undo_and_redo() {
let mut engine = Engine::default();
assert!(!engine.can_undo() && !engine.can_redo());
new(&mut engine, "a");
assert!(engine.can_undo() && !engine.can_redo());
assert!(engine.execute(Action::Undo));
assert!(!engine.can_undo() && engine.can_redo());
// selecting does not change the history
new(&mut engine, "b");
let a = engine.order()[0];
select_files(&mut engine, &[a]);
assert!(engine.can_undo() && !engine.can_redo());
assert!(engine.execute(Action::Undo));
assert!(engine.execute(Action::Redo));
assert!(engine.can_undo() && !engine.can_redo());
}
#[test]
fn test_select_all_follows_the_selected_level() {
let mut engine = Engine::default();
new(&mut engine, "a");
new(&mut engine, "b");
let order = engine.order().to_vec();
select_files(&mut engine, &[order[0]]);
// files
assert!(engine.execute(Action::SelectAll));
assert_eq!(engine.selection(), &files(&order));
// everything is selected already
assert!(!engine.execute(Action::SelectAll));
// the tracks of a file
let load = |engine: &mut Engine| load(engine, "data/with_tracks_and_segments.gpx");
load(&mut engine);
let file = engine
.stack
.current()
.unwrap()
.values()
.find(|f| !f.trk.is_empty())
.unwrap()
.clone();
let tracks: HashSet<_> = file.trk.iter().map(|t| t.id).collect();
assert!(tracks.len() >= 2);
select_elements(
&mut engine,
Selection::Track {
file_id: file.id,
trk_ids: [file.trk[0].id].into(),
},
SelectMode::Replace,
);
assert!(engine.execute(Action::SelectAll));
assert_eq!(
engine.selection(),
&Selection::Track {
file_id: file.id,
trk_ids: tracks
}
);
// nothing selected: all the files
select_elements(&mut engine, Selection::Empty, SelectMode::Replace);
assert!(engine.execute(Action::SelectAll));
assert!(matches!(engine.selection(), Selection::File { file_ids } if file_ids.len() == 3));
// the waypoints node: nothing more to select
select_elements(
&mut engine,
Selection::Waypoints { file_id: file.id },
SelectMode::Replace,
);
assert!(!engine.execute(Action::SelectAll));
assert_eq!(
engine.selection(),
&Selection::Waypoints { file_id: file.id }
);
}
#[test]
fn test_arrow_select() {
let mut engine = Engine::default();
new(&mut engine, "file");
for _ in 0..3 {
edit(&mut engine, Command::NewTrack(NewTrack));
}
let file = engine
.stack
.current()
.unwrap()
.values()
.next()
.unwrap()
.clone();
let ids: Vec<TrackId> = file.trk.iter().map(|t| t.id).collect();
assert_eq!(ids.len(), 3);
let tracks = |ids: &[TrackId]| Selection::Track {
file_id: file.id,
trk_ids: ids.iter().copied().collect(),
};
let arrow =
|engine: &mut Engine, down, add| engine.execute(Action::ArrowSelect { down, add });
select_elements(&mut engine, tracks(&[ids[0]]), SelectMode::Replace);
// without shift the next one replaces the selection
assert!(arrow(&mut engine, true, false));
assert_eq!(engine.selection(), &tracks(&[ids[1]]));
assert!(engine.selection_changed());
// with shift it is added
assert!(arrow(&mut engine, true, true));
assert_eq!(engine.selection(), &tracks(&[ids[1], ids[2]]));
// the selection grows from its end, and the other way round from its start
assert!(arrow(&mut engine, false, true));
assert_eq!(engine.selection(), &tracks(&[ids[0], ids[1], ids[2]]));
// at the ends nothing happens
assert!(!arrow(&mut engine, false, false));
assert!(!arrow(&mut engine, true, true));
assert_eq!(engine.selection(), &tracks(&[ids[0], ids[1], ids[2]]));
// and there is nothing to move without selection
select_elements(&mut engine, Selection::Empty, SelectMode::Replace);
assert!(!arrow(&mut engine, true, false));
assert_eq!(engine.selection(), &Selection::Empty);
}
#[test]
fn test_arrow_select_does_not_create_undo_steps() {
let mut engine = Engine::default();
new(&mut engine, "a");
new(&mut engine, "b");
let a = engine.order()[0];
select_files(&mut engine, &[a]);
let before = engine.can_redo();
assert!(engine.execute(Action::ArrowSelect {
down: true,
add: false
}));
assert_eq!(engine.can_redo(), before);
assert_eq!(engine.selection(), &files(&[engine.order()[1]]));
assert!(engine.execute(Action::Undo));
assert_eq!(engine.order().len(), 1);
}
}
+384
View File
@@ -196,6 +196,158 @@ impl Selection {
}
}
/// Position of the first and last of the `selected` ids among the `all` ids.
fn selected_range<T: PartialEq>(
all: impl Iterator<Item = T>,
selected: &HashSet<T>,
) -> Option<(usize, usize)>
where
T: std::hash::Hash + Eq,
{
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: &StackEntry, 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()
.flat_map(|chunk| &chunk.wpt)
.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: &StackEntry, 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()
.flat_map(|chunk| &chunk.wpt)
.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;
@@ -372,4 +524,236 @@ mod tests {
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: StackEntry,
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(Rc::new(WaypointChunk {
wpt: vec![Waypoint::default(), Waypoint::default()],
..Default::default()
}));
file.wpt.push(Rc::new(WaypointChunk {
wpt: vec![Waypoint::default()],
..Default::default()
}));
let tree = 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()
.flat_map(|chunk| &chunk.wpt)
.map(|w| w.id)
.collect(),
order: vec![file.id],
files: StackEntry::from([(file.id, Rc::new(file))]),
};
tree
}
#[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 = StackEntry::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);
}
}