diff --git a/gpx-rs/engine/src/engine/action.rs b/gpx-rs/engine/src/engine/action.rs index 86adb697a..ea0220ef7 100644 --- a/gpx-rs/engine/src/engine/action.rs +++ b/gpx-rs/engine/src/engine/action.rs @@ -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, diff --git a/gpx-rs/engine/src/engine/command/file/delete.rs b/gpx-rs/engine/src/engine/command/file/delete.rs index 9099e9bcc..b8edf3b83 100644 --- a/gpx-rs/engine/src/engine/command/file/delete.rs +++ b/gpx-rs/engine/src/engine/command/file/delete.rs @@ -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) ); } diff --git a/gpx-rs/engine/src/engine/engine.rs b/gpx-rs/engine/src/engine/engine.rs index 5cadcd5cd..af29c9ecc 100644 --- a/gpx-rs/engine/src/engine/engine.rs +++ b/gpx-rs/engine/src/engine/engine.rs @@ -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 = 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); + } } diff --git a/gpx-rs/engine/src/engine/state/selection.rs b/gpx-rs/engine/src/engine/state/selection.rs index feeaabc36..b3447c1ce 100644 --- a/gpx-rs/engine/src/engine/state/selection.rs +++ b/gpx-rs/engine/src/engine/state/selection.rs @@ -196,6 +196,158 @@ impl Selection { } } +/// Position of the first and last of the `selected` ids among the `all` ids. +fn selected_range( + all: impl Iterator, + selected: &HashSet, +) -> 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( + ids: &[T], + selected: &HashSet, + down: bool, +) -> Option { + 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 { + 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 { + 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 = 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 = 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 = 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, + file: FileId, + tracks: Vec, + segments: Vec>, + waypoints: Vec, + } + + 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); + } } diff --git a/gpx-rs/wasm/src/lib.rs b/gpx-rs/wasm/src/lib.rs index 8f55700e1..c6e5ac11d 100644 --- a/gpx-rs/wasm/src/lib.rs +++ b/gpx-rs/wasm/src/lib.rs @@ -182,9 +182,11 @@ pub fn duplicate() -> bool { edit(Command::Duplicate(engine::Duplicate)) } +/// 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. #[wasm_bindgen] -pub fn delete() -> bool { - edit(Command::Delete(engine::Delete)) +pub fn delete(whole_files: bool) -> bool { + edit(Command::Delete(engine::Delete { whole_files })) } #[wasm_bindgen] @@ -346,6 +348,16 @@ pub fn redo() -> bool { execute(Action::Redo) } +#[wasm_bindgen] +pub fn can_undo() -> bool { + with_engine(|e| e.can_undo()).unwrap_or(false) +} + +#[wasm_bindgen] +pub fn can_redo() -> bool { + with_engine(|e| e.can_redo()).unwrap_or(false) +} + // Selection /// Selects files. `file_ids_bytes`: concatenated 16-byte UUIDs. Unknown files are ignored, and @@ -360,6 +372,22 @@ pub fn select(file_ids_bytes: &[u8], mode: SelectMode) -> bool { ) } +/// Selects all the elements of the same kind as the selected ones, in the same place: all the +/// files, the tracks of the file, the segments of the track, the waypoints of the file. All the +/// files when nothing is selected. +#[wasm_bindgen] +pub fn select_all() -> bool { + execute(Action::SelectAll) +} + +/// Moves the selection to the next (`down`) or previous element of the same kind, like the arrow +/// keys do in the file list. With `add` (shift + arrow), the element is added to the selection, +/// otherwise it replaces it. +#[wasm_bindgen] +pub fn arrow_select(down: bool, add: bool) -> bool { + execute(Action::ArrowSelect { down, add }) +} + // 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. See `SelectMode`: elements are merged // with the selection when it holds elements of the same kind in the same place (same file, same diff --git a/website/src/lib/components/Menu.svelte b/website/src/lib/components/Menu.svelte index 554ba2fc0..1c2d04958 100644 --- a/website/src/lib/components/Menu.svelte +++ b/website/src/lib/components/Menu.svelte @@ -53,7 +53,7 @@ import { anySelectedLayer } from '$lib/components/map/layer-control/utils'; import { defaultOverlays } from '$lib/assets/layers'; import LayerControlSettings from '$lib/components/map/layer-control/LayerControlSettings.svelte'; - import { ListFileItem, ListTrackItem } from '$lib/components/file-list/file-list'; + import { ListLevel } from '$lib/components/file-list/file-list'; import Export from '$lib/components/export/Export.svelte'; import { mode, setMode } from 'mode-watcher'; import { i18n } from '$lib/i18n.svelte'; @@ -62,17 +62,17 @@ import { settings } from '$lib/logic/settings'; import { createFile, - fileActions, loadFiles, pasteSelection, triggerFileInput, } from '$lib/logic/file-actions'; - import { fileStateCollection } from '$lib/logic/file-state'; - import { fileActionManager } from '$lib/logic/file-action-manager'; - import { copied, selection } from '$lib/logic/selection'; - import { allHidden } from '$lib/logic/hidden'; + import { engine } from '$lib/engine'; + import { allHidden } from '$lib/all-hidden'; + import { selectionSize } from '$lib/selection-helpers'; + // TODO the clipboard and centering on the selection still work on the previous implementation + import { copied, selection as oldSelection } from '$lib/logic/selection'; import { boundsManager } from '$lib/logic/bounds'; - import { tick, onMount } from 'svelte'; + import { onMount } from 'svelte'; import { allowedPastes } from '$lib/components/file-list/sortable-file-list'; const { @@ -91,8 +91,22 @@ routing, } = settings; - const canUndo = fileActionManager.canUndo; - const canRedo = fileActionManager.canRedo; + const { files, selection, canUndo, canRedo } = engine; + + let selectionCount = $derived(selectionSize($selection)); + let noFiles = $derived($files.size === 0); + // metadata and style can be edited for files and tracks only + let filesOrTracksSelected = $derived($selection.type === 'file' || $selection.type === 'track'); + let selectionLevel = $derived( + { + empty: undefined, + file: ListLevel.FILE, + track: ListLevel.TRACK, + segment: ListLevel.SEGMENT, + waypoints: ListLevel.WAYPOINTS, + waypoint: ListLevel.WAYPOINT, + }[$selection.type] + ); function switchBasemaps() { [$currentBasemap, $previousBasemap] = [$previousBasemap, $currentBasemap]; @@ -153,27 +167,21 @@ - + engine.duplicate()} disabled={selectionCount == 0}> {i18n._('menu.duplicate')} tick().then(fileActions.deleteSelectedFiles)} - disabled={$selection.size == 0} + onclick={() => engine.delete(true)} + disabled={selectionCount == 0} > {i18n._('menu.delete')} - + engine.deleteAll()} disabled={noFiles}> {i18n._('menu.delete_all')} @@ -181,7 +189,7 @@ (exportState.current = ExportState.SELECTION)} - disabled={$selection.size == 0} + disabled={selectionCount == 0} > {i18n._('menu.export')} @@ -189,7 +197,7 @@ (exportState.current = ExportState.ALL)} - disabled={fileStateCollection.size == 0} + disabled={noFiles} > {i18n._('menu.export_all')} @@ -203,26 +211,19 @@ - fileActionManager.undo()} disabled={!$canUndo}> + engine.undo()} disabled={!$canUndo}> {i18n._('menu.undo')} - fileActionManager.redo()} disabled={!$canRedo}> + engine.redo()} disabled={!$canRedo}> {i18n._('menu.redo')} - item instanceof ListFileItem || - item instanceof ListTrackItem - )} + disabled={selectionCount !== 1 || !filesOrTracksSelected} onclick={() => (editMetadata.current = true)} > @@ -230,28 +231,15 @@ - item instanceof ListFileItem || - item instanceof ListTrackItem - )} + disabled={selectionCount === 0 || !filesOrTracksSelected} onclick={() => (editStyle.current = true)} > {i18n._('menu.style.button')} { - if ($allHidden) { - fileActions.setHiddenToSelection(false); - } else { - fileActions.setHiddenToSelection(true); - } - }} - disabled={$selection.size == 0} + onclick={() => engine.setSelectionHidden(!$allHidden)} + disabled={selectionCount == 0} > {#if $allHidden} @@ -263,31 +251,20 @@ {#if $treeFileView} - {#if $selection.getSelected().some((item) => item instanceof ListFileItem)} + {#if $selection.type === 'file'} - fileActions.addNewTrack( - $selection.getSelected()[0].getFileId() - )} - disabled={$selection.size !== 1} + onclick={() => engine.newTrack()} + disabled={selectionCount !== 1} > {i18n._('menu.new_track')} - {:else if $selection - .getSelected() - .some((item) => item instanceof ListTrackItem)} + {:else if $selection.type === 'track'} { - let item = $selection.getSelected()[0]; - fileActions.addNewSegment( - item.getFileId(), - item.getTrackIndex() - ); - }} - disabled={$selection.size !== 1} + onclick={() => engine.newTrackSegment()} + disabled={selectionCount !== 1} > {i18n._('menu.new_segment')} @@ -295,21 +272,18 @@ {/if} {/if} - selection.selectAll()} - disabled={fileStateCollection.size == 0} - > + engine.selectAll()} disabled={noFiles}> {i18n._('menu.select_all')} { - if ($selection.size > 0) { + if (selectionCount > 0) { boundsManager.centerMapOnSelection(); } }} - disabled={$selection.size == 0} + disabled={selectionCount == 0} > {i18n._('menu.center')} @@ -318,16 +292,16 @@ {#if $treeFileView} selection.copySelection()} - disabled={$selection.size === 0} + onclick={() => oldSelection.copySelection()} + disabled={selectionCount === 0} > {i18n._('menu.copy')} selection.cutSelection()} - disabled={$selection.size === 0} + onclick={() => oldSelection.cutSelection()} + disabled={selectionCount === 0} > {i18n._('menu.cut')} @@ -336,10 +310,8 @@ 0 && - !allowedPastes[$copied[0].level].includes( - $selection.getSelected().pop()!.level - ))} + (selectionLevel !== undefined && + !allowedPastes[$copied[0].level].includes(selectionLevel))} onclick={pasteSelection} > @@ -348,10 +320,7 @@ {/if} - tick().then(fileActions.deleteSelection)} - disabled={$selection.size == 0} - > + engine.delete()} disabled={selectionCount == 0}> {i18n._('menu.delete')} @@ -585,16 +554,16 @@ triggerFileInput(); e.preventDefault(); } else if (e.key === 'd' && (e.metaKey || e.ctrlKey)) { - fileActions.duplicateSelection(); + engine.duplicate(); e.preventDefault(); } else if (e.key === 'c' && (e.metaKey || e.ctrlKey)) { if (!targetInput) { - selection.copySelection(); + oldSelection.copySelection(); e.preventDefault(); } } else if (e.key === 'x' && (e.metaKey || e.ctrlKey)) { if (!targetInput) { - selection.cutSelection(); + oldSelection.cutSelection(); e.preventDefault(); } } else if (e.key === 'v' && (e.metaKey || e.ctrlKey)) { @@ -604,41 +573,36 @@ } } else if ((e.key === 's' || e.key == 'S') && (e.metaKey || e.ctrlKey)) { if (e.shiftKey) { - if (fileStateCollection.size > 0) { + if (!noFiles) { exportState.current = ExportState.ALL; } - } else if ($selection.size > 0) { + } else if (selectionCount > 0) { exportState.current = ExportState.SELECTION; } e.preventDefault(); } else if ((e.key === 'z' || e.key == 'Z') && (e.metaKey || e.ctrlKey)) { if (e.shiftKey) { - fileActionManager.redo(); + engine.redo(); } else { - fileActionManager.undo(); + engine.undo(); } e.preventDefault(); } else if ((e.key === 'Backspace' || e.key === 'Delete') && (e.metaKey || e.ctrlKey)) { if (!targetInput) { if (e.shiftKey) { - fileActions.deleteAllFiles(); + engine.deleteAll(); } else { - fileActions.deleteSelection(); + engine.delete(); } e.preventDefault(); } } else if (e.key === 'a' && (e.metaKey || e.ctrlKey)) { if (!targetInput) { - selection.selectAll(); + engine.selectAll(); e.preventDefault(); } } else if (e.key === 'i' && (e.metaKey || e.ctrlKey)) { - if ( - $selection.size === 1 && - $selection - .getSelected() - .every((item) => item instanceof ListFileItem || item instanceof ListTrackItem) - ) { + if (selectionCount === 1 && filesOrTracksSelected) { editMetadata.current = true; } e.preventDefault(); @@ -649,14 +613,10 @@ $treeFileView = !$treeFileView; e.preventDefault(); } else if (e.key === 'h' && (e.metaKey || e.ctrlKey)) { - if ($allHidden) { - fileActions.setHiddenToSelection(false); - } else { - fileActions.setHiddenToSelection(true); - } + engine.setSelectionHidden(!$allHidden); e.preventDefault(); } else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { - if ($selection.size > 0) { + if (selectionCount > 0) { boundsManager.centerMapOnSelection(); } } else if (e.key === 'F1') { @@ -681,10 +641,7 @@ e.key === 'ArrowUp' ) { if (!targetInput) { - selection.updateFromKey( - e.key === 'ArrowRight' || e.key === 'ArrowDown', - e.shiftKey - ); + engine.arrowSelect(e.key === 'ArrowRight' || e.key === 'ArrowDown', e.shiftKey); e.preventDefault(); } } diff --git a/website/src/lib/components/file-list/FileListNodeLabel.svelte b/website/src/lib/components/file-list/FileListNodeLabel.svelte index c07bea4f3..468ffb7c0 100644 --- a/website/src/lib/components/file-list/FileListNodeLabel.svelte +++ b/website/src/lib/components/file-list/FileListNodeLabel.svelte @@ -260,7 +260,7 @@ {/if} {/if} {#if level !== ListLevel.WAYPOINTS} - engine.selectAllSiblings(node)}> + engine.selectAll()}> {i18n._('menu.select_all')} diff --git a/website/src/lib/engine.ts b/website/src/lib/engine.ts index 820896542..fecb428cb 100644 --- a/website/src/lib/engine.ts +++ b/website/src/lib/engine.ts @@ -197,6 +197,8 @@ class Engine { private _visibility = new Map(); private _files = writable>>(new Map()); private _selection = writable({ type: 'empty' }); + private _canUndo = writable(false); + private _canRedo = writable(false); private _statistics = writable(EMPTY_SELECTION_STATISTICS); /** Identifies the statistics currently in the engine's buffers. */ private _statisticsVersion = 0; @@ -205,6 +207,8 @@ class Engine { readonly order: Readable = { subscribe: this._order.subscribe }; /** Statistics of what is currently selected. */ readonly statistics: Readable = { subscribe: this._statistics.subscribe }; + readonly canUndo: Readable = { subscribe: this._canUndo.subscribe }; + readonly canRedo: Readable = { subscribe: this._canRedo.subscribe }; /** What is currently selected. */ readonly selection: Readable = { subscribe: this._selection.subscribe }; /** @@ -239,8 +243,12 @@ class Engine { return this.run((w) => w.duplicate()); } - delete() { - return this.run((w) => w._delete()); + /** + * Deletes the selected elements. With `wholeFiles`, the files holding the selected elements + * are deleted instead, even if only a track or a waypoint is selected. + */ + delete(wholeFiles = false) { + return this.run((w) => w._delete(wholeFiles)); } deleteAll() { @@ -263,8 +271,22 @@ class Engine { return this.run((w) => w.select(idsToBytes(fileIds), selectMode(w, mode))); } + /** + * Selects all the elements of the same kind as the selected ones, in the same place: all the + * files, the tracks of the file, the segments of the track, the waypoints of the file. All + * the files when nothing is selected. + */ selectAll() { - return this.select(get(this._order)); + return this.run((w) => w.select_all()); + } + + /** + * Moves the selection to the next (`down`) or previous element of the same kind, like the + * arrow keys do. With `add` (shift + arrow), the element is added to the selection, otherwise + * it replaces it. + */ + arrowSelect(down: boolean, add: boolean) { + return this.run((w) => w.arrow_select(down, add)); } // Selecting elements inside a file. Ids that do not exist are ignored. @@ -316,36 +338,6 @@ class Engine { } } - /** Selects the node and its siblings (all the files, the tracks of a file, and so on). */ - selectAllSiblings(node: FileTreeNode) { - const state = get(this._files).get(node.fileId); - if (node.type === 'file' || !state) { - return this.selectAll(); - } - const { tracks, waypoints } = get(state).structure; - switch (node.type) { - case 'track': - return this.selectTracks( - node.fileId, - tracks.map((track) => track.id) - ); - case 'segment': - return this.selectSegments( - node.fileId, - node.trackId, - tracks.find((track) => track.id === node.trackId)?.segments.map((s) => s.id) ?? - [] - ); - case 'waypoint': - return this.selectWaypoints( - node.fileId, - waypoints.map((waypoint) => waypoint.id) - ); - case 'waypoints': - return this.selectWaypointGroup(node.fileId); - } - } - // Edits of the selection newTrack() { @@ -414,6 +406,8 @@ class Engine { /** Reads what the last action changed. */ private sync(wasm: Wasm) { const update = wasm.last_update(); + this._canUndo.set(wasm.can_undo()); + this._canRedo.set(wasm.can_redo()); if (update.selectionChanged) { this._selection.set(wasm.selection()); }