diff --git a/gpx-rs/engine/src/engine/action.rs b/gpx-rs/engine/src/engine/action.rs index 6f6aeb310..871275cc4 100644 --- a/gpx-rs/engine/src/engine/action.rs +++ b/gpx-rs/engine/src/engine/action.rs @@ -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, + selection: Selection, + add: bool, }, - AddSelect { - file_ids: Vec, - }, - SelectAll, /// Moves the files to `index` among the other files, in the given order. Not undoable. Reorder { file_ids: Vec, diff --git a/gpx-rs/engine/src/engine/engine.rs b/gpx-rs/engine/src/engine/engine.rs index b258924fa..6df8593e5 100644 --- a/gpx-rs/engine/src/engine/engine.rs +++ b/gpx-rs/engine/src/engine/engine.rs @@ -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); + } } diff --git a/gpx-rs/engine/src/engine/state/selection.rs b/gpx-rs/engine/src/engine/state/selection.rs index 0f4d792ad..3ead2ba2b 100644 --- a/gpx-rs/engine/src/engine/state/selection.rs +++ b/gpx-rs/engine/src/engine/state/selection.rs @@ -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() { - *self = Selection::Empty; - } + file_ids.is_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; + 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; + } + } + + /// 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)); + } } diff --git a/gpx-rs/wasm/src/lib.rs b/gpx-rs/wasm/src/lib.rs index c4d261816..60f2a0766 100644 --- a/gpx-rs/wasm/src/lib.rs +++ b/gpx-rs/wasm/src/lib.rs @@ -66,14 +66,18 @@ fn edit(command: Command) -> bool { } /// Decodes concatenated 16-byte UUIDs. -fn file_ids(bytes: &[u8]) -> Option> { +fn uuid_list(bytes: &[u8]) -> Option> { 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> { + uuid_list(bytes).map(|ids| ids.into_iter().map(FileId).collect()) +} + +fn parse_file_id(id: &str) -> Option { + 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, 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 diff --git a/website/src/lib/engine.ts b/website/src/lib/engine.ts index 074dc1bb3..5f0084929 100644 --- a/website/src/lib/engine.ts +++ b/website/src/lib/engine.ts @@ -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. */