This commit is contained in:
vcoppe
2026-10-04 09:50:34 +02:00
parent cd870f0186
commit 84b1558b2e
5 changed files with 421 additions and 71 deletions
+7 -6
View File
@@ -1,4 +1,4 @@
use crate::{Command, FileId};
use crate::{Command, FileId, Selection};
/// Everything the frontend can ask the engine to do.
///
@@ -9,13 +9,14 @@ pub enum Action<'a> {
Edit(Command<'a>),
Undo,
Redo,
/// Selects files, tracks, segments or waypoints (or the waypoints node of one file),
/// ignoring what does not exist: a selection left with nothing is empty, which deselects
/// everything. With `add`, the elements are added to the current selection when they can
/// be merged with it (see [`Selection::extend`]), otherwise they replace it.
Select {
file_ids: Vec<FileId>,
selection: Selection,
add: bool,
},
AddSelect {
file_ids: Vec<FileId>,
},
SelectAll,
/// Moves the files to `index` among the other files, in the given order. Not undoable.
Reorder {
file_ids: Vec<FileId>,
+131 -28
View File
@@ -81,20 +81,18 @@ impl Engine {
Action::Edit(command) => self.edit(command),
Action::Undo => self.stack.undo(),
Action::Redo => self.stack.redo(),
Action::Select { file_ids } => {
self.selection = Selection::File {
file_ids: file_ids.into_iter().collect(),
};
None
}
Action::AddSelect { file_ids } => {
match &mut self.selection {
Selection::File { file_ids: ids } => ids.extend(file_ids),
selection => {
*selection = Selection::File {
file_ids: file_ids.into_iter().collect(),
Action::Select { mut selection, add } => {
match self.stack.current() {
Some(files) => selection.retain_existing(files),
None => selection = Selection::Empty,
}
if add {
// nothing to add: keep the selection
if selection != Selection::Empty {
self.selection.extend(selection);
}
} else {
self.selection = selection;
}
None
}
@@ -102,12 +100,6 @@ impl Engine {
self.order.move_files(&file_ids, index);
None
}
Action::SelectAll => {
self.selection = Selection::File {
file_ids: self.order.0.iter().copied().collect(),
};
None
}
};
let changed = self.diff.is_some()
|| self.selection != selection_before
@@ -156,7 +148,7 @@ impl Engine {
#[cfg(test)]
mod tests {
use crate::{Load, Metadata, New, NewTrack, Style};
use crate::{Load, Metadata, New, NewTrack, Style, TrackId, TrackSegmentId};
use super::*;
@@ -402,12 +394,12 @@ mod tests {
let a = engine.order()[0];
// a new file is selected by the edit
assert!(engine.selection_changed());
assert!(!engine.execute(Action::Select { file_ids: vec![a] }));
assert!(!select_files(&mut engine, &[a]));
assert!(!engine.selection_changed());
assert!(engine.execute(Action::Select { file_ids: vec![] }));
assert!(select_files(&mut engine, &[]));
assert!(engine.selection_changed());
// undoing drops the selected file from the selection
engine.execute(Action::Select { file_ids: vec![a] });
select_files(&mut engine, &[a]);
assert!(engine.execute(Action::Undo));
assert!(engine.selection_changed());
assert_eq!(engine.selection(), &Selection::Empty);
@@ -421,7 +413,7 @@ mod tests {
engine.execute(Action::Undo);
assert!(engine.statistics().total_distance.is_empty());
engine.execute(Action::Redo);
engine.execute(Action::SelectAll);
select_all(&mut engine);
assert_eq!(engine.statistics().total_distance.len(), 80);
}
@@ -434,14 +426,14 @@ mod tests {
let b = selected(&engine)[0];
assert_eq!(selected(&engine), vec![b]);
assert!(engine.execute(Action::Select { file_ids: vec![a] }));
assert!(select_files(&mut engine, &[a]));
assert_eq!(selected(&engine), vec![a]);
assert!(engine.execute(Action::AddSelect { file_ids: vec![b] }));
assert!(select_elements(&mut engine, files(&[b]), true));
assert_eq!(selected(&engine).len(), 2);
engine.execute(Action::Select { file_ids: vec![] });
assert!(engine.execute(Action::SelectAll));
select_files(&mut engine, &[]);
assert!(select_all(&mut engine));
assert_eq!(selected(&engine).len(), 2);
}
@@ -449,8 +441,119 @@ mod tests {
fn test_selection_does_not_create_undo_steps() {
let mut engine = Engine::default();
new(&mut engine, "a");
engine.execute(Action::SelectAll);
select_all(&mut engine);
engine.execute(Action::Undo);
assert!(engine.stack.current().is_none());
}
fn select_elements(engine: &mut Engine, selection: Selection, add: bool) -> bool {
engine.execute(Action::Select { selection, add })
}
fn files(ids: &[FileId]) -> Selection {
Selection::File {
file_ids: ids.iter().copied().collect(),
}
}
fn select_files(engine: &mut Engine, ids: &[FileId]) -> bool {
select_elements(engine, files(ids), false)
}
fn select_all(engine: &mut Engine) -> bool {
let ids = engine.order().to_vec();
select_files(engine, &ids)
}
#[test]
fn test_select_elements() {
let mut engine = Engine::default();
load(&mut engine, "data/with_tracks_and_segments.gpx");
let file = engine
.stack
.current()
.unwrap()
.values()
.next()
.unwrap()
.clone();
assert!(file.trk.len() >= 2 && file.trk[0].trkseg.len() >= 2);
let (t0, t1) = (file.trk[0].id, file.trk[1].id);
let (s0, s1) = (file.trk[0].trkseg[0].id, file.trk[0].trkseg[1].id);
// tracks, then more tracks
let tracks = |ids: &[TrackId]| Selection::Track {
file_id: file.id,
trk_ids: ids.iter().copied().collect(),
};
assert!(select_elements(&mut engine, tracks(&[t0]), false));
assert_eq!(engine.selection(), &tracks(&[t0]));
assert!(select_elements(&mut engine, tracks(&[t1]), true));
assert_eq!(engine.selection(), &tracks(&[t0, t1]));
// segments of a track, added only within the same track
let segments = |trk_id, ids: &[TrackSegmentId]| Selection::TrackSegment {
file_id: file.id,
trk_id,
trkseg_ids: ids.iter().copied().collect(),
};
assert!(select_elements(&mut engine, segments(t0, &[s0]), true));
assert_eq!(engine.selection(), &segments(t0, &[s0]));
assert!(select_elements(&mut engine, segments(t0, &[s1]), true));
assert_eq!(engine.selection(), &segments(t0, &[s0, s1]));
// the statistics follow the selected segments
assert_eq!(
engine.statistics().total_distance.len(),
file.trk[0].trkseg[0].len() + file.trk[0].trkseg[1].len()
);
// the waypoints node
let node = Selection::Waypoints { file_id: file.id };
assert!(select_elements(&mut engine, node.clone(), false));
assert!(!select_elements(&mut engine, node.clone(), true));
assert_eq!(engine.selection(), &node);
assert!(engine.selection_changed() == false);
// unknown elements and files are ignored when adding
let unknown = tracks(&[TrackId::default()]);
assert!(!select_elements(&mut engine, unknown.clone(), true));
let unknown_file = Selection::Waypoints {
file_id: FileId::default(),
};
assert!(!select_elements(&mut engine, unknown_file.clone(), true));
assert!(!select_elements(&mut engine, Selection::Empty, true));
assert_eq!(engine.selection(), &node);
// and select nothing otherwise
assert!(select_elements(&mut engine, unknown, false));
assert_eq!(engine.selection(), &Selection::Empty);
select_elements(&mut engine, node, false);
assert!(select_elements(&mut engine, unknown_file, false));
assert_eq!(engine.selection(), &Selection::Empty);
}
#[test]
fn test_selected_elements_are_dropped_when_they_disappear() {
let mut engine = Engine::default();
load(&mut engine, "data/with_tracks_and_segments.gpx");
let file = engine
.stack
.current()
.unwrap()
.values()
.next()
.unwrap()
.clone();
let trk_id = file.trk[0].id;
select_elements(
&mut engine,
Selection::Track {
file_id: file.id,
trk_ids: [trk_id].into(),
},
false,
);
engine.execute(Action::Undo);
assert_eq!(engine.selection(), &Selection::Empty);
}
}
+178 -12
View File
@@ -28,24 +28,92 @@ pub enum Selection {
}
impl Selection {
/// Drops the files that do not exist anymore (e.g. after an undo).
/// 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: &StackEntry) {
match self {
Selection::Empty => {}
let empty = match self {
Selection::Empty => false,
Selection::File { file_ids } => {
file_ids.retain(|id| files.contains_key(id));
if file_ids.is_empty() {
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()
.flat_map(|chunk| &chunk.wpt)
.any(|wpt| wpt.id == *id)
});
wpt_ids.is_empty()
}
None => true,
},
};
if empty {
*self = Selection::Empty;
}
}
Selection::Track { file_id, .. }
| Selection::TrackSegment { file_id, .. }
| Selection::Waypoints { file_id }
| Selection::Waypoint { file_id, .. } => {
if !files.contains_key(file_id) {
*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,
}
}
}
@@ -54,7 +122,7 @@ impl Selection {
mod tests {
use std::rc::Rc;
use crate::File;
use crate::{File, Track, TrackSegment, Waypoint, WaypointChunk};
use super::*;
@@ -86,4 +154,102 @@ mod tests {
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(Rc::new(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].wpt[0].id);
let mut files = StackEntry::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));
}
}
+75 -16
View File
@@ -66,14 +66,18 @@ fn edit(command: Command) -> bool {
}
/// Decodes concatenated 16-byte UUIDs.
fn file_ids(bytes: &[u8]) -> Option<Vec<FileId>> {
fn uuid_list(bytes: &[u8]) -> Option<Vec<uuid::Uuid>> {
let (chunks, rest) = bytes.as_chunks::<16>();
rest.is_empty().then(|| {
chunks
.iter()
.map(|c| FileId(uuid::Uuid::from_bytes(*c)))
.collect()
})
rest.is_empty()
.then(|| chunks.iter().map(|c| uuid::Uuid::from_bytes(*c)).collect())
}
fn file_ids(bytes: &[u8]) -> Option<Vec<FileId>> {
uuid_list(bytes).map(|ids| ids.into_iter().map(FileId).collect())
}
fn parse_file_id(id: &str) -> Option<FileId> {
uuid::Uuid::parse_str(id).ok().map(FileId)
}
fn same_len(a: &[f64], b: &[f64], c: &[f64]) -> bool {
@@ -301,21 +305,76 @@ pub fn redo() -> bool {
// Selection
/// `file_ids_bytes`: concatenated 16-byte UUIDs.
/// Selects files. `file_ids_bytes`: concatenated 16-byte UUIDs. Unknown files are ignored, and
/// selecting nothing deselects everything. With `add`, the files are added to the selection if it
/// already holds files.
#[wasm_bindgen]
pub fn select(file_ids_bytes: &[u8]) -> bool {
file_ids(file_ids_bytes).is_some_and(|file_ids| execute(Action::Select { file_ids }))
pub fn select(file_ids_bytes: &[u8], add: bool) -> bool {
select_elements(
file_ids(file_ids_bytes).map(|ids| engine::Selection::File {
file_ids: ids.into_iter().collect(),
}),
add,
)
}
/// `file_ids_bytes`: concatenated 16-byte UUIDs.
#[wasm_bindgen]
pub fn add_select(file_ids_bytes: &[u8]) -> bool {
file_ids(file_ids_bytes).is_some_and(|file_ids| execute(Action::AddSelect { file_ids }))
// The elements below cross as the id of their file (UUID string) and, like the files, as
// concatenated 16-byte UUIDs. What does not exist is ignored. With `add`, the elements are added
// to the selection when it holds elements of the same kind in the same place (same file, same
// track for segments), otherwise they replace it.
fn select_elements(selection: Option<engine::Selection>, add: bool) -> bool {
selection.is_some_and(|selection| execute(Action::Select { selection, add }))
}
#[wasm_bindgen]
pub fn select_all() -> bool {
execute(Action::SelectAll)
pub fn select_tracks(file_id: &str, track_ids_bytes: &[u8], add: bool) -> bool {
select_elements(
parse_file_id(file_id)
.zip(uuid_list(track_ids_bytes))
.map(|(file_id, ids)| engine::Selection::Track {
file_id,
trk_ids: ids.into_iter().map(engine::TrackId).collect(),
}),
add,
)
}
#[wasm_bindgen]
pub fn select_segments(file_id: &str, track_id: &str, segment_ids_bytes: &[u8], add: bool) -> bool {
select_elements(
parse_file_id(file_id)
.zip(uuid::Uuid::parse_str(track_id).ok())
.zip(uuid_list(segment_ids_bytes))
.map(|((file_id, trk_id), ids)| engine::Selection::TrackSegment {
file_id,
trk_id: engine::TrackId(trk_id),
trkseg_ids: ids.into_iter().map(engine::TrackSegmentId).collect(),
}),
add,
)
}
/// Selects the node standing for all the waypoints of a file.
#[wasm_bindgen]
pub fn select_waypoint_group(file_id: &str) -> bool {
select_elements(
parse_file_id(file_id).map(|file_id| engine::Selection::Waypoints { file_id }),
false,
)
}
#[wasm_bindgen]
pub fn select_waypoints(file_id: &str, waypoint_ids_bytes: &[u8], add: bool) -> bool {
select_elements(
parse_file_id(file_id)
.zip(uuid_list(waypoint_ids_bytes))
.map(|(file_id, ids)| engine::Selection::Waypoint {
file_id,
wpt_ids: ids.into_iter().map(engine::WaypointId).collect(),
}),
add,
)
}
// File order and structures
+28 -7
View File
@@ -154,16 +154,37 @@ class Engine {
return this.run((w) => w.redo());
}
select(fileIds: string[]) {
return this.run((w) => w.select(idsToBytes(fileIds)));
}
addSelect(fileIds: string[]) {
return this.run((w) => w.add_select(idsToBytes(fileIds)));
/**
* Selects files. With `add`, they are added to the selection if it already holds files.
* Selecting nothing (or only unknown files) deselects everything.
*/
select(fileIds: string[], add = false) {
return this.run((w) => w.select(idsToBytes(fileIds), add));
}
selectAll() {
return this.run((w) => w.select_all());
return this.select(get(this._order));
}
// Selecting elements inside a file. With `add`, they are added to the selection when it holds
// elements of the same kind in the same place (same file, same track for segments),
// otherwise they replace it. Ids that do not exist are ignored.
selectTracks(fileId: string, trackIds: string[], add = false) {
return this.run((w) => w.select_tracks(fileId, idsToBytes(trackIds), add));
}
selectSegments(fileId: string, trackId: string, segmentIds: string[], add = false) {
return this.run((w) => w.select_segments(fileId, trackId, idsToBytes(segmentIds), add));
}
/** Selects the node standing for all the waypoints of the file. */
selectWaypointGroup(fileId: string) {
return this.run((w) => w.select_waypoint_group(fileId));
}
selectWaypoints(fileId: string, waypointIds: string[], add = false) {
return this.run((w) => w.select_waypoints(fileId, idsToBytes(waypointIds), add));
}
/** Moves the files, in the given order, to `index` among the other files. */