diff --git a/gpx-rs/engine/src/engine/action.rs b/gpx-rs/engine/src/engine/action.rs index 871275cc4..86adb697a 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, Selection}; +use crate::{Command, FileId, SelectMode, Selection}; /// Everything the frontend can ask the engine to do. /// @@ -11,11 +11,10 @@ pub enum Action<'a> { 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. + /// everything. See [`SelectMode`] for how it combines with the current selection. Select { selection: Selection, - add: bool, + mode: SelectMode, }, /// Moves the files to `index` among the other files, in the given order. Not undoable. Reorder { diff --git a/gpx-rs/engine/src/engine/engine.rs b/gpx-rs/engine/src/engine/engine.rs index 6df8593e5..83301158c 100644 --- a/gpx-rs/engine/src/engine/engine.rs +++ b/gpx-rs/engine/src/engine/engine.rs @@ -2,7 +2,7 @@ use crate::{ Action, Apply, Command, CoordinatesCache, Diff, FileId, FileOrder, FileStructure, - FileStructureCache, GlobalStatistics, Selection, Stack, State, StatisticsBuffer, + FileStructureCache, GlobalStatistics, SelectMode, Selection, Stack, State, StatisticsBuffer, StatisticsCache, TrackSegmentId, }; @@ -81,18 +81,20 @@ impl Engine { Action::Edit(command) => self.edit(command), Action::Undo => self.stack.undo(), Action::Redo => self.stack.redo(), - Action::Select { mut selection, add } => { + Action::Select { + mut selection, + mode, + } => { 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; + match mode { + SelectMode::Replace => self.selection = selection, + // nothing to add or toggle: keep the selection + _ if selection == Selection::Empty => {} + SelectMode::Add => self.selection.extend(selection), + SelectMode::Toggle => self.selection.toggle(selection), } None } @@ -429,7 +431,7 @@ mod tests { assert!(select_files(&mut engine, &[a])); assert_eq!(selected(&engine), vec![a]); - assert!(select_elements(&mut engine, files(&[b]), true)); + assert!(select_elements(&mut engine, files(&[b]), SelectMode::Add)); assert_eq!(selected(&engine).len(), 2); select_files(&mut engine, &[]); @@ -446,8 +448,8 @@ mod tests { assert!(engine.stack.current().is_none()); } - fn select_elements(engine: &mut Engine, selection: Selection, add: bool) -> bool { - engine.execute(Action::Select { selection, add }) + fn select_elements(engine: &mut Engine, selection: Selection, mode: SelectMode) -> bool { + engine.execute(Action::Select { selection, mode }) } fn files(ids: &[FileId]) -> Selection { @@ -457,7 +459,7 @@ mod tests { } fn select_files(engine: &mut Engine, ids: &[FileId]) -> bool { - select_elements(engine, files(ids), false) + select_elements(engine, files(ids), SelectMode::Replace) } fn select_all(engine: &mut Engine) -> bool { @@ -486,9 +488,13 @@ mod tests { file_id: file.id, trk_ids: ids.iter().copied().collect(), }; - assert!(select_elements(&mut engine, tracks(&[t0]), false)); + assert!(select_elements( + &mut engine, + tracks(&[t0]), + SelectMode::Replace + )); assert_eq!(engine.selection(), &tracks(&[t0])); - assert!(select_elements(&mut engine, tracks(&[t1]), true)); + assert!(select_elements(&mut engine, tracks(&[t1]), SelectMode::Add)); assert_eq!(engine.selection(), &tracks(&[t0, t1])); // segments of a track, added only within the same track @@ -497,9 +503,17 @@ mod tests { trk_id, trkseg_ids: ids.iter().copied().collect(), }; - assert!(select_elements(&mut engine, segments(t0, &[s0]), true)); + assert!(select_elements( + &mut engine, + segments(t0, &[s0]), + SelectMode::Add + )); assert_eq!(engine.selection(), &segments(t0, &[s0])); - assert!(select_elements(&mut engine, segments(t0, &[s1]), true)); + assert!(select_elements( + &mut engine, + segments(t0, &[s1]), + SelectMode::Add + )); assert_eq!(engine.selection(), &segments(t0, &[s0, s1])); // the statistics follow the selected segments assert_eq!( @@ -509,26 +523,80 @@ mod tests { // 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!(select_elements( + &mut engine, + node.clone(), + SelectMode::Replace + )); + assert!(!select_elements(&mut engine, node.clone(), SelectMode::Add)); 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)); + assert!(!select_elements( + &mut engine, + unknown.clone(), + SelectMode::Add + )); 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!(!select_elements( + &mut engine, + unknown_file.clone(), + SelectMode::Add + )); + assert!(!select_elements( + &mut engine, + Selection::Empty, + SelectMode::Add + )); assert_eq!(engine.selection(), &node); // and select nothing otherwise - assert!(select_elements(&mut engine, unknown, false)); + assert!(select_elements(&mut engine, unknown, SelectMode::Replace)); assert_eq!(engine.selection(), &Selection::Empty); - select_elements(&mut engine, node, false); - assert!(select_elements(&mut engine, unknown_file, false)); + select_elements(&mut engine, node, SelectMode::Replace); + assert!(select_elements( + &mut engine, + unknown_file, + SelectMode::Replace + )); + assert_eq!(engine.selection(), &Selection::Empty); + } + + #[test] + fn test_toggle_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(); + let (t0, t1) = (file.trk[0].id, file.trk[1].id); + let tracks = |ids: &[TrackId]| Selection::Track { + file_id: file.id, + trk_ids: ids.iter().copied().collect(), + }; + let toggle = |engine: &mut Engine, ids: &[TrackId]| { + select_elements(engine, tracks(ids), SelectMode::Toggle) + }; + + select_elements(&mut engine, tracks(&[t0]), SelectMode::Replace); + assert!(toggle(&mut engine, &[t1])); + assert_eq!(engine.selection(), &tracks(&[t0, t1])); + assert!(toggle(&mut engine, &[t0])); + assert_eq!(engine.selection(), &tracks(&[t1])); + // toggling the last element deselects everything + assert!(toggle(&mut engine, &[t1])); + assert_eq!(engine.selection(), &Selection::Empty); + // unknown elements change nothing + assert!(!toggle(&mut engine, &[TrackId::default()])); assert_eq!(engine.selection(), &Selection::Empty); } @@ -551,7 +619,7 @@ mod tests { file_id: file.id, trk_ids: [trk_id].into(), }, - false, + SelectMode::Replace, ); 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 3ead2ba2b..feeaabc36 100644 --- a/gpx-rs/engine/src/engine/state/selection.rs +++ b/gpx-rs/engine/src/engine/state/selection.rs @@ -2,6 +2,19 @@ use std::collections::HashSet; use crate::{FileId, StackEntry, TrackId, TrackSegmentId, WaypointId}; +/// How a new selection combines with the current one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +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] @@ -116,6 +129,71 @@ impl Selection { (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(ids: &mut HashSet, other: HashSet) { + 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; + } + } } #[cfg(test)] @@ -252,4 +330,46 @@ mod tests { 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])); + } } diff --git a/gpx-rs/wasm/src/lib.rs b/gpx-rs/wasm/src/lib.rs index 60f2a0766..384cf96dc 100644 --- a/gpx-rs/wasm/src/lib.rs +++ b/gpx-rs/wasm/src/lib.rs @@ -30,6 +30,26 @@ pub enum CleanType { Outside, } +/// How a selection combines with the current one: `Replace` it, `Add` to it, or `Toggle` the +/// elements (the ones already selected are removed). +#[wasm_bindgen] +#[derive(Clone, Copy)] +pub enum SelectMode { + Replace, + Add, + Toggle, +} + +impl From for engine::SelectMode { + fn from(m: SelectMode) -> Self { + match m { + SelectMode::Replace => Self::Replace, + SelectMode::Add => Self::Add, + SelectMode::Toggle => Self::Toggle, + } + } +} + impl From for engine::MergeType { fn from(t: MergeType) -> Self { match t { @@ -306,29 +326,33 @@ pub fn redo() -> bool { // Selection /// 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. +/// selecting nothing deselects everything. See `SelectMode`. #[wasm_bindgen] -pub fn select(file_ids_bytes: &[u8], add: bool) -> bool { +pub fn select(file_ids_bytes: &[u8], mode: SelectMode) -> bool { select_elements( file_ids(file_ids_bytes).map(|ids| engine::Selection::File { file_ids: ids.into_iter().collect(), }), - add, + mode, ) } // 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 +// concatenated 16-byte UUIDs. What does not exist is ignored. See `SelectMode`: elements are merged +// with 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 })) +fn select_elements(selection: Option, mode: SelectMode) -> bool { + selection.is_some_and(|selection| { + execute(Action::Select { + selection, + mode: mode.into(), + }) + }) } #[wasm_bindgen] -pub fn select_tracks(file_id: &str, track_ids_bytes: &[u8], add: bool) -> bool { +pub fn select_tracks(file_id: &str, track_ids_bytes: &[u8], mode: SelectMode) -> bool { select_elements( parse_file_id(file_id) .zip(uuid_list(track_ids_bytes)) @@ -336,12 +360,17 @@ pub fn select_tracks(file_id: &str, track_ids_bytes: &[u8], add: bool) -> bool { file_id, trk_ids: ids.into_iter().map(engine::TrackId).collect(), }), - add, + mode, ) } #[wasm_bindgen] -pub fn select_segments(file_id: &str, track_id: &str, segment_ids_bytes: &[u8], add: bool) -> bool { +pub fn select_segments( + file_id: &str, + track_id: &str, + segment_ids_bytes: &[u8], + mode: SelectMode, +) -> bool { select_elements( parse_file_id(file_id) .zip(uuid::Uuid::parse_str(track_id).ok()) @@ -351,21 +380,21 @@ pub fn select_segments(file_id: &str, track_id: &str, segment_ids_bytes: &[u8], trk_id: engine::TrackId(trk_id), trkseg_ids: ids.into_iter().map(engine::TrackSegmentId).collect(), }), - add, + mode, ) } /// Selects the node standing for all the waypoints of a file. #[wasm_bindgen] -pub fn select_waypoint_group(file_id: &str) -> bool { +pub fn select_waypoint_group(file_id: &str, mode: SelectMode) -> bool { select_elements( parse_file_id(file_id).map(|file_id| engine::Selection::Waypoints { file_id }), - false, + mode, ) } #[wasm_bindgen] -pub fn select_waypoints(file_id: &str, waypoint_ids_bytes: &[u8], add: bool) -> bool { +pub fn select_waypoints(file_id: &str, waypoint_ids_bytes: &[u8], mode: SelectMode) -> bool { select_elements( parse_file_id(file_id) .zip(uuid_list(waypoint_ids_bytes)) @@ -373,7 +402,7 @@ pub fn select_waypoints(file_id: &str, waypoint_ids_bytes: &[u8], add: bool) -> file_id, wpt_ids: ids.into_iter().map(engine::WaypointId).collect(), }), - add, + mode, ) } diff --git a/website/src/lib/components/map/gpx-layer/gpx-layer.ts b/website/src/lib/components/map/gpx-layer/gpx-layer.ts index 77e0a6508..c2d447bfb 100644 --- a/website/src/lib/components/map/gpx-layer/gpx-layer.ts +++ b/website/src/lib/components/map/gpx-layer/gpx-layer.ts @@ -444,19 +444,19 @@ export class GPXLayer { return; } - const add = e.originalEvent.ctrlKey || e.originalEvent.metaKey; + const mode = e.originalEvent.ctrlKey || e.originalEvent.metaKey ? 'add' : 'replace'; const { tracks } = get(this.file).structure; const segmentCount = tracks.reduce((count, track) => count + track.segments.length, 0); if (get(treeFileView) && segmentCount > 1) { // Select inner item const track = tracks.find((track) => track.id === trackId); if (track && track.segments.length > 1) { - engine.selectSegments(this.fileId, trackId, [segmentId], add); + engine.selectSegments(this.fileId, trackId, [segmentId], mode); } else { - engine.selectTracks(this.fileId, [trackId], add); + engine.selectTracks(this.fileId, [trackId], mode); } } else { - engine.select([this.fileId], add); + engine.select([this.fileId], mode); } } @@ -515,7 +515,11 @@ export class GPXLayer { } else { if (get(treeFileView)) { const add = e.originalEvent.ctrlKey || e.originalEvent.metaKey; - engine.selectWaypoints(this.fileId, [waypointId], add && this.selected); + engine.selectWaypoints( + this.fileId, + [waypointId], + add && this.selected ? 'add' : 'replace' + ); } else { if (!this.selected) { engine.select([this.fileId]); diff --git a/website/src/lib/engine.ts b/website/src/lib/engine.ts index 5f0084929..b5bfe54ce 100644 --- a/website/src/lib/engine.ts +++ b/website/src/lib/engine.ts @@ -17,6 +17,22 @@ export type { type Wasm = typeof import('gpx-rs'); +/** + * How elements combine with the current selection: they `replace` it, are `add`ed to it, or are + * `toggle`d (the ones already selected are removed). Adding and toggling only merge with elements + * of the same kind in the same place (same file, same track for segments), otherwise the new + * elements replace the selection. + */ +export type SelectMode = 'replace' | 'add' | 'toggle'; + +function selectMode(wasm: Wasm, mode: SelectMode) { + return { + replace: wasm.SelectMode.Replace, + add: wasm.SelectMode.Add, + toggle: wasm.SelectMode.Toggle, + }[mode]; +} + export type SegmentProperties = { fileId: string; trackId: string; @@ -155,36 +171,43 @@ class Engine { } /** - * Selects files. With `add`, they are added to the selection if it already holds files. - * Selecting nothing (or only unknown files) deselects everything. + * Selects files. Selecting nothing (or only unknown files) deselects everything. See + * `SelectMode` for how the files combine with the selection. */ - select(fileIds: string[], add = false) { - return this.run((w) => w.select(idsToBytes(fileIds), add)); + select(fileIds: string[], mode: SelectMode = 'replace') { + return this.run((w) => w.select(idsToBytes(fileIds), selectMode(w, mode))); } selectAll() { 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. + // Selecting elements inside a file. 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)); + selectTracks(fileId: string, trackIds: string[], mode: SelectMode = 'replace') { + return this.run((w) => w.select_tracks(fileId, idsToBytes(trackIds), selectMode(w, mode))); } - selectSegments(fileId: string, trackId: string, segmentIds: string[], add = false) { - return this.run((w) => w.select_segments(fileId, trackId, idsToBytes(segmentIds), add)); + selectSegments( + fileId: string, + trackId: string, + segmentIds: string[], + mode: SelectMode = 'replace' + ) { + return this.run((w) => + w.select_segments(fileId, trackId, idsToBytes(segmentIds), selectMode(w, mode)) + ); } /** Selects the node standing for all the waypoints of the file. */ - selectWaypointGroup(fileId: string) { - return this.run((w) => w.select_waypoint_group(fileId)); + selectWaypointGroup(fileId: string, mode: SelectMode = 'replace') { + return this.run((w) => w.select_waypoint_group(fileId, selectMode(w, mode))); } - selectWaypoints(fileId: string, waypointIds: string[], add = false) { - return this.run((w) => w.select_waypoints(fileId, idsToBytes(waypointIds), add)); + selectWaypoints(fileId: string, waypointIds: string[], mode: SelectMode = 'replace') { + return this.run((w) => + w.select_waypoints(fileId, idsToBytes(waypointIds), selectMode(w, mode)) + ); } /** Moves the files, in the given order, to `index` among the other files. */