From bff84fa24c8dd0459e01c62400774cfb9ed3cd2d Mon Sep 17 00:00:00 2001 From: vcoppe Date: Sat, 3 Oct 2026 16:40:17 +0200 Subject: [PATCH] progress --- gpx-rs/engine/src/engine/action.rs | 15 + gpx-rs/engine/src/engine/command/applier.rs | 114 ------- gpx-rs/engine/src/engine/command/apply.rs | 33 ++ gpx-rs/engine/src/engine/command/command.rs | 150 ++++----- .../src/engine/command/edit/metadata.rs | 86 +++++ gpx-rs/engine/src/engine/command/edit/mod.rs | 9 + .../src/engine/command/edit/new_track.rs | 36 ++ .../engine/command/edit/new_track_segment.rs | 36 ++ .../engine/src/engine/command/edit/style.rs | 66 ++++ .../engine/src/engine/command/file/delete.rs | 11 + .../src/engine/command/file/delete_all.rs | 38 +++ .../src/engine/command/file/duplicate.rs | 11 + gpx-rs/engine/src/engine/command/file/load.rs | 48 +++ gpx-rs/engine/src/engine/command/file/mod.rs | 11 + gpx-rs/engine/src/engine/command/file/new.rs | 36 ++ gpx-rs/engine/src/engine/command/fixture.rs | 30 ++ gpx-rs/engine/src/engine/command/mod.rs | 12 +- gpx-rs/engine/src/engine/command/pattern.rs | 179 ---------- .../engine/src/engine/command/pattern/mod.rs | 5 + .../src/engine/command/pattern/produce.rs | 81 +++++ .../engine/command/pattern/update_selected.rs | 266 +++++++++++++++ .../engine/src/engine/command/tools/append.rs | 15 + .../engine/src/engine/command/tools/clean.rs | 22 ++ .../engine/src/engine/command/tools/crop.rs | 14 + .../src/engine/command/tools/elevation.rs | 13 + .../src/engine/command/tools/extract.rs | 11 + .../engine/src/engine/command/tools/merge.rs | 19 ++ gpx-rs/engine/src/engine/command/tools/mod.rs | 25 ++ .../src/engine/command/tools/move_waypoint.rs | 15 + .../src/engine/command/tools/new_waypoint.rs | 19 ++ .../src/engine/command/tools/replace.rs | 17 + .../src/engine/command/tools/reverse.rs | 11 + .../engine/src/engine/command/tools/split.rs | 13 + .../engine/src/engine/command/tools/time.rs | 11 + gpx-rs/engine/src/engine/engine.rs | 308 ++++++++++++------ gpx-rs/engine/src/engine/mod.rs | 2 + gpx-rs/engine/src/engine/state/selection.rs | 63 +++- gpx-rs/wasm/src/lib.rs | 80 +++-- 38 files changed, 1413 insertions(+), 518 deletions(-) create mode 100644 gpx-rs/engine/src/engine/action.rs delete mode 100644 gpx-rs/engine/src/engine/command/applier.rs create mode 100644 gpx-rs/engine/src/engine/command/apply.rs create mode 100644 gpx-rs/engine/src/engine/command/edit/metadata.rs create mode 100644 gpx-rs/engine/src/engine/command/edit/mod.rs create mode 100644 gpx-rs/engine/src/engine/command/edit/new_track.rs create mode 100644 gpx-rs/engine/src/engine/command/edit/new_track_segment.rs create mode 100644 gpx-rs/engine/src/engine/command/edit/style.rs create mode 100644 gpx-rs/engine/src/engine/command/file/delete.rs create mode 100644 gpx-rs/engine/src/engine/command/file/delete_all.rs create mode 100644 gpx-rs/engine/src/engine/command/file/duplicate.rs create mode 100644 gpx-rs/engine/src/engine/command/file/load.rs create mode 100644 gpx-rs/engine/src/engine/command/file/mod.rs create mode 100644 gpx-rs/engine/src/engine/command/file/new.rs create mode 100644 gpx-rs/engine/src/engine/command/fixture.rs delete mode 100644 gpx-rs/engine/src/engine/command/pattern.rs create mode 100644 gpx-rs/engine/src/engine/command/pattern/mod.rs create mode 100644 gpx-rs/engine/src/engine/command/pattern/produce.rs create mode 100644 gpx-rs/engine/src/engine/command/pattern/update_selected.rs create mode 100644 gpx-rs/engine/src/engine/command/tools/append.rs create mode 100644 gpx-rs/engine/src/engine/command/tools/clean.rs create mode 100644 gpx-rs/engine/src/engine/command/tools/crop.rs create mode 100644 gpx-rs/engine/src/engine/command/tools/elevation.rs create mode 100644 gpx-rs/engine/src/engine/command/tools/extract.rs create mode 100644 gpx-rs/engine/src/engine/command/tools/merge.rs create mode 100644 gpx-rs/engine/src/engine/command/tools/mod.rs create mode 100644 gpx-rs/engine/src/engine/command/tools/move_waypoint.rs create mode 100644 gpx-rs/engine/src/engine/command/tools/new_waypoint.rs create mode 100644 gpx-rs/engine/src/engine/command/tools/replace.rs create mode 100644 gpx-rs/engine/src/engine/command/tools/reverse.rs create mode 100644 gpx-rs/engine/src/engine/command/tools/split.rs create mode 100644 gpx-rs/engine/src/engine/command/tools/time.rs diff --git a/gpx-rs/engine/src/engine/action.rs b/gpx-rs/engine/src/engine/action.rs new file mode 100644 index 000000000..833e7d3ef --- /dev/null +++ b/gpx-rs/engine/src/engine/action.rs @@ -0,0 +1,15 @@ +use crate::{Command, FileId}; + +/// Everything the frontend can ask the engine to do. +/// +/// Only [`Action::Edit`] changes the files (and records an undo step); the others act on the +/// history or on the selection. +#[derive(Debug)] +pub enum Action<'a> { + Edit(Command<'a>), + Undo, + Redo, + Select { file_ids: Vec }, + AddSelect { file_ids: Vec }, + SelectAll, +} diff --git a/gpx-rs/engine/src/engine/command/applier.rs b/gpx-rs/engine/src/engine/command/applier.rs deleted file mode 100644 index fbdfdb722..000000000 --- a/gpx-rs/engine/src/engine/command/applier.rs +++ /dev/null @@ -1,114 +0,0 @@ -use crate::{File, State, parse, produce, update_each_selected_file}; - -pub fn create_file(state: &mut State, name: &str) -> Result<(), String> { - produce(state, |_| { - let mut file = File::default(); - file.info.name = name.to_owned(); - vec![file] - }); - Ok(()) -} - -pub fn load_file(state: &mut State, data: &[u8]) -> Result<(), String> { - let file = parse(data).map_err(|err| err.to_string())?; - produce(state, |_| vec![file]); - Ok(()) -} - -pub fn update_metadata(state: &mut State, name: &str, desc: &str) -> Result<(), String> { - update_each_selected_file(state, &mut |file| { - let mut next = (*file).clone(); - next.info.name = name.to_owned(); - next.info.desc = Some(desc.to_owned()); - next - }); - Ok(()) -} - -#[cfg(test)] -mod tests { - use std::collections::HashSet; - - use crate::{FileId, FileOrder, Selection, StackEntry}; - - use super::*; - - #[derive(Default)] - struct Fixture { - files: StackEntry, - selection: Selection, - order: FileOrder, - } - - impl Fixture { - fn state(&mut self) -> State<'_> { - State { - files: &mut self.files, - selection: &mut self.selection, - order: &mut self.order, - } - } - - fn selected_ids(&self) -> HashSet { - match &self.selection { - Selection::File { file_ids } => file_ids.clone(), - _ => HashSet::new(), - } - } - } - - #[test] - fn test_create_file() { - let mut fx = Fixture::default(); - assert!(create_file(&mut fx.state(), "new").is_ok()); - assert_eq!(fx.files.len(), 1); - let file = fx.files.values().next().unwrap(); - assert_eq!(file.info.name, "new"); - assert!(file.trk.is_empty()); - assert_eq!(fx.selected_ids(), HashSet::from([file.id])); - assert_eq!(fx.order.0, vec![file.id]); - } - - #[test] - fn test_load_file() { - let mut fx = Fixture::default(); - let data = std::fs::read("data/with_tracks.gpx").unwrap(); - assert!(load_file(&mut fx.state(), &data).is_ok()); - assert_eq!(fx.files.len(), 1); - let file = fx.files.values().next().unwrap(); - assert!(!file.trk.is_empty()); - assert_eq!(fx.selected_ids(), HashSet::from([file.id])); - } - - #[test] - fn test_load_invalid_file_changes_nothing() { - let mut fx = Fixture::default(); - assert!(create_file(&mut fx.state(), "keep").is_ok()); - let selected = fx.selected_ids(); - - assert!(load_file(&mut fx.state(), b"").is_err()); - assert_eq!(fx.files.len(), 1); - assert_eq!(fx.order.0.len(), 1); - assert_eq!(fx.selected_ids(), selected); - } - - #[test] - fn test_update_metadata_applies_to_selected_file() { - let mut fx = Fixture::default(); - create_file(&mut fx.state(), "first").unwrap(); - create_file(&mut fx.state(), "second").unwrap(); // now selected - let selected = *fx.selected_ids().iter().next().unwrap(); - - update_metadata(&mut fx.state(), "renamed", "description").unwrap(); - - for (id, file) in fx.files.iter() { - if *id == selected { - assert_eq!(file.info.name, "renamed"); - assert_eq!(file.info.desc.as_deref(), Some("description")); - } else { - assert_eq!(file.info.name, "first"); - assert_eq!(file.info.desc, None); - } - } - } -} diff --git a/gpx-rs/engine/src/engine/command/apply.rs b/gpx-rs/engine/src/engine/command/apply.rs new file mode 100644 index 000000000..2edd1193d --- /dev/null +++ b/gpx-rs/engine/src/engine/command/apply.rs @@ -0,0 +1,33 @@ +use std::fmt; + +use crate::State; + +/// The effect of a command on the engine state. +/// +/// Each command is a small struct holding its arguments, with its effect implemented in its +/// own file. Effects are built from the reusable patterns in `pattern/`. +pub trait Apply { + fn apply(self, state: &mut State) -> Result<(), CommandError>; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CommandError { + /// The command is not implemented yet. + NotImplemented(&'static str), + /// The command would not change anything. + NothingToDo, + /// The input data could not be parsed. + InvalidData(String), +} + +impl fmt::Display for CommandError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotImplemented(name) => write!(f, "{name} is not implemented yet"), + Self::NothingToDo => write!(f, "nothing to do"), + Self::InvalidData(err) => write!(f, "invalid data: {err}"), + } + } +} + +impl std::error::Error for CommandError {} diff --git a/gpx-rs/engine/src/engine/command/command.rs b/gpx-rs/engine/src/engine/command/command.rs index e1a53f8bc..935987ac1 100644 --- a/gpx-rs/engine/src/engine/command/command.rs +++ b/gpx-rs/engine/src/engine/command/command.rs @@ -1,102 +1,64 @@ -use crate::{FileId, LngLatBounds}; +use crate::{ + Append, Clean, Crop, Delete, DeleteAll, Duplicate, Elevation, Extract, Load, Merge, Metadata, + MoveWaypoint, New, NewTrack, NewTrackSegment, NewWaypoint, Replace, Reverse, Split, Style, + Time, +}; +use crate::{Apply, CommandError, State}; -/// A user action, decoded from the WASM boundary. +/// A user action that edits the files. Each variant wraps the command's own struct, whose +/// effect is implemented in its own file (see `Apply`). /// /// Bulk data (coordinates, file bytes) is borrowed straight from the wasm-bindgen argument /// buffers, so a command is built without copying; the engine only copies what it keeps. +#[derive(Debug)] pub enum Command<'a> { - // File commands - New { - name: &'a str, - }, - Load { - data: &'a [u8], - }, - Duplicate, - Delete, - DeleteAll, - // Edit commands - Metadata { - name: &'a str, - desc: &'a str, - }, - Style { - color: Option<&'a str>, - opacity: Option, - width: Option, - }, - NewTrack, - NewTrackSegment, - // Tools - Reverse, - Append { - lng: &'a [f64], - lat: &'a [f64], - ele: &'a [f64], - }, - Replace { - start: u32, - end: u32, - lng: &'a [f64], - lat: &'a [f64], - ele: &'a [f64], - }, - NewWaypoint { - lng: f64, - lat: f64, - ele: f64, - name: &'a str, - desc: &'a str, - icon: &'a str, - link: &'a str, - }, - MoveWaypoint { - lng: f64, - lat: f64, - ele: f64, - }, - Crop { - start: u32, - end: u32, - }, - Split { - at: u32, - }, - Time, - Merge { - type_: MergeType, - }, - Extract, - Elevation { - ele: &'a [f64], - }, - Clean { - bounds: LngLatBounds, - type_: CleanType, - trkpt: bool, - wpt: bool, - }, - // Undo-redo - Undo, - Redo, - // Selection - Select { - file_ids: Vec, - }, - AddSelect { - file_ids: Vec, - }, - SelectAll, + New(New<'a>), + Load(Load<'a>), + Duplicate(Duplicate), + Delete(Delete), + DeleteAll(DeleteAll), + Metadata(Metadata<'a>), + Style(Style<'a>), + NewTrack(NewTrack), + NewTrackSegment(NewTrackSegment), + Reverse(Reverse), + Append(Append<'a>), + Replace(Replace<'a>), + NewWaypoint(NewWaypoint<'a>), + MoveWaypoint(MoveWaypoint), + Crop(Crop), + Split(Split), + Time(Time), + Merge(Merge), + Extract(Extract), + Elevation(Elevation<'a>), + Clean(Clean), } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MergeType { - Connect, - Group, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CleanType { - Inside, - Outside, +impl Apply for Command<'_> { + fn apply(self, state: &mut State) -> Result<(), CommandError> { + match self { + Command::New(c) => c.apply(state), + Command::Load(c) => c.apply(state), + Command::Duplicate(c) => c.apply(state), + Command::Delete(c) => c.apply(state), + Command::DeleteAll(c) => c.apply(state), + Command::Metadata(c) => c.apply(state), + Command::Style(c) => c.apply(state), + Command::NewTrack(c) => c.apply(state), + Command::NewTrackSegment(c) => c.apply(state), + Command::Reverse(c) => c.apply(state), + Command::Append(c) => c.apply(state), + Command::Replace(c) => c.apply(state), + Command::NewWaypoint(c) => c.apply(state), + Command::MoveWaypoint(c) => c.apply(state), + Command::Crop(c) => c.apply(state), + Command::Split(c) => c.apply(state), + Command::Time(c) => c.apply(state), + Command::Merge(c) => c.apply(state), + Command::Extract(c) => c.apply(state), + Command::Elevation(c) => c.apply(state), + Command::Clean(c) => c.apply(state), + } + } } diff --git a/gpx-rs/engine/src/engine/command/edit/metadata.rs b/gpx-rs/engine/src/engine/command/edit/metadata.rs new file mode 100644 index 000000000..0b88bba06 --- /dev/null +++ b/gpx-rs/engine/src/engine/command/edit/metadata.rs @@ -0,0 +1,86 @@ +use crate::{Apply, CommandError, Edit, Editor, File, State, Track, update_selected}; + +#[derive(Debug)] +pub struct Metadata<'a> { + pub name: &'a str, + pub desc: &'a str, +} + +impl Apply for Metadata<'_> { + fn apply(mut self, state: &mut State) -> Result<(), CommandError> { + update_selected(state, &mut self); + Ok(()) + } +} + +impl Editor for Metadata<'_> { + fn file(&mut self, file: &mut File) -> Edit { + file.info.name = self.name.to_owned(); + file.info.desc = Some(self.desc.to_owned()); + Edit::Changed + } + + fn track(&mut self, track: &mut Track) -> Edit { + track.info.name = Some(self.name.to_owned()); + track.info.desc = Some(self.desc.to_owned()); + Edit::Changed + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use crate::{New, Selection, engine::command::fixture::Fixture}; + + use super::*; + + #[test] + fn test_metadata_applies_to_selected_file() { + let mut fx = Fixture::default(); + New { name: "first" }.apply(&mut fx.state()).unwrap(); + New { name: "second" }.apply(&mut fx.state()).unwrap(); // now selected + let selected = *fx.selected_files().iter().next().unwrap(); + + Metadata { + name: "renamed", + desc: "description", + } + .apply(&mut fx.state()) + .unwrap(); + + for (id, file) in fx.files.iter() { + if *id == selected { + assert_eq!(file.info.name, "renamed"); + assert_eq!(file.info.desc.as_deref(), Some("description")); + } else { + assert_eq!(file.info.name, "first"); + assert_eq!(file.info.desc, None); + } + } + } + + #[test] + fn test_metadata_applies_to_selected_track() { + let mut fx = Fixture::default(); + let data = std::fs::read("data/with_tracks.gpx").unwrap(); + crate::Load { data: &data }.apply(&mut fx.state()).unwrap(); + let file = fx.files.values().next().unwrap().clone(); + fx.selection = Selection::Track { + file_id: file.id, + trk_ids: HashSet::from([file.trk[0].id]), + }; + + Metadata { + name: "renamed", + desc: "", + } + .apply(&mut fx.state()) + .unwrap(); + + let after = &fx.files[&file.id]; + assert_eq!(after.info.name, file.info.name); + assert_eq!(after.trk[0].info.name.as_deref(), Some("renamed")); + assert_eq!(after.trk[1].info, file.trk[1].info); + } +} diff --git a/gpx-rs/engine/src/engine/command/edit/mod.rs b/gpx-rs/engine/src/engine/command/edit/mod.rs new file mode 100644 index 000000000..18f73284e --- /dev/null +++ b/gpx-rs/engine/src/engine/command/edit/mod.rs @@ -0,0 +1,9 @@ +mod metadata; +mod new_track; +mod new_track_segment; +mod style; + +pub use metadata::*; +pub use new_track::*; +pub use new_track_segment::*; +pub use style::*; diff --git a/gpx-rs/engine/src/engine/command/edit/new_track.rs b/gpx-rs/engine/src/engine/command/edit/new_track.rs new file mode 100644 index 000000000..ff90ee736 --- /dev/null +++ b/gpx-rs/engine/src/engine/command/edit/new_track.rs @@ -0,0 +1,36 @@ +use crate::{Apply, CommandError, Edit, Editor, File, State, Track, update_selected}; + +#[derive(Debug)] +pub struct NewTrack; + +impl Apply for NewTrack { + fn apply(mut self, state: &mut State) -> Result<(), CommandError> { + update_selected(state, &mut self); + Ok(()) + } +} + +impl Editor for NewTrack { + fn file(&mut self, file: &mut File) -> Edit { + file.trk.push(Track::default()); + Edit::Changed + } +} + +#[cfg(test)] +mod tests { + use crate::{New, engine::command::fixture::Fixture}; + + use super::*; + + #[test] + fn test_new_track() { + let mut fx = Fixture::default(); + New { name: "file" }.apply(&mut fx.state()).unwrap(); + NewTrack.apply(&mut fx.state()).unwrap(); + NewTrack.apply(&mut fx.state()).unwrap(); + let file = fx.files.values().next().unwrap(); + assert_eq!(file.trk.len(), 2); + assert_ne!(file.trk[0].id, file.trk[1].id); + } +} diff --git a/gpx-rs/engine/src/engine/command/edit/new_track_segment.rs b/gpx-rs/engine/src/engine/command/edit/new_track_segment.rs new file mode 100644 index 000000000..a91594730 --- /dev/null +++ b/gpx-rs/engine/src/engine/command/edit/new_track_segment.rs @@ -0,0 +1,36 @@ +use crate::{Apply, CommandError, Edit, Editor, State, Track, TrackSegment, update_selected}; + +#[derive(Debug)] +pub struct NewTrackSegment; + +impl Apply for NewTrackSegment { + fn apply(mut self, state: &mut State) -> Result<(), CommandError> { + update_selected(state, &mut self); + Ok(()) + } +} + +impl Editor for NewTrackSegment { + fn track(&mut self, track: &mut Track) -> Edit { + track.trkseg.push(TrackSegment::default()); + Edit::Changed + } +} + +#[cfg(test)] +mod tests { + use crate::{New, NewTrack, engine::command::fixture::Fixture}; + + use super::*; + + #[test] + fn test_new_track_segment_on_each_track_of_selected_file() { + let mut fx = Fixture::default(); + New { name: "file" }.apply(&mut fx.state()).unwrap(); + NewTrack.apply(&mut fx.state()).unwrap(); + NewTrack.apply(&mut fx.state()).unwrap(); + NewTrackSegment.apply(&mut fx.state()).unwrap(); + let file = fx.files.values().next().unwrap(); + assert!(file.trk.iter().all(|t| t.trkseg.len() == 1)); + } +} diff --git a/gpx-rs/engine/src/engine/command/edit/style.rs b/gpx-rs/engine/src/engine/command/edit/style.rs new file mode 100644 index 000000000..009f98d94 --- /dev/null +++ b/gpx-rs/engine/src/engine/command/edit/style.rs @@ -0,0 +1,66 @@ +use crate::{Apply, CommandError, Edit, Editor, State, Track, update_selected}; + +#[derive(Debug)] +pub struct Style<'a> { + pub color: Option<&'a str>, + pub opacity: Option, + pub width: Option, +} + +impl Apply for Style<'_> { + fn apply(mut self, state: &mut State) -> Result<(), CommandError> { + update_selected(state, &mut self); + Ok(()) + } +} + +impl Editor for Style<'_> { + fn track(&mut self, track: &mut Track) -> Edit { + if let Some(color) = self.color { + track.info.color = Some(color.to_owned()); + } + if self.opacity.is_some() { + track.info.opacity = self.opacity; + } + if self.width.is_some() { + track.info.width = self.width; + } + Edit::Changed + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use crate::{Selection, engine::command::fixture::Fixture}; + + use super::*; + + #[test] + fn test_style_applies_to_all_tracks_of_selected_file_and_keeps_unset_fields() { + let mut fx = Fixture::default(); + let data = std::fs::read("data/with_style.gpx").unwrap(); + crate::Load { data: &data }.apply(&mut fx.state()).unwrap(); + let id = *fx.files.keys().next().unwrap(); + let before = fx.files[&id].clone(); + assert!(!before.trk.is_empty()); + fx.selection = Selection::File { + file_ids: HashSet::from([id]), + }; + + Style { + color: Some("ff0000"), + opacity: None, + width: Some(7.0), + } + .apply(&mut fx.state()) + .unwrap(); + + for (b, a) in before.trk.iter().zip(fx.files[&id].trk.iter()) { + assert_eq!(a.info.color.as_deref(), Some("ff0000")); + assert_eq!(a.info.width, Some(7.0)); + assert_eq!(a.info.opacity, b.info.opacity); + } + } +} diff --git a/gpx-rs/engine/src/engine/command/file/delete.rs b/gpx-rs/engine/src/engine/command/file/delete.rs new file mode 100644 index 000000000..d1aa610f7 --- /dev/null +++ b/gpx-rs/engine/src/engine/command/file/delete.rs @@ -0,0 +1,11 @@ +use crate::{Apply, CommandError, State}; + +#[derive(Debug)] +pub struct Delete; + +impl Apply for Delete { + fn apply(self, _state: &mut State) -> Result<(), CommandError> { + // TODO + Err(CommandError::NotImplemented("delete")) + } +} diff --git a/gpx-rs/engine/src/engine/command/file/delete_all.rs b/gpx-rs/engine/src/engine/command/file/delete_all.rs new file mode 100644 index 000000000..d57cc8f2f --- /dev/null +++ b/gpx-rs/engine/src/engine/command/file/delete_all.rs @@ -0,0 +1,38 @@ +use crate::{Apply, CommandError, Selection, State}; + +#[derive(Debug)] +pub struct DeleteAll; + +impl Apply for DeleteAll { + fn apply(self, state: &mut State) -> Result<(), CommandError> { + if state.files.is_empty() { + return Err(CommandError::NothingToDo); + } + state.files.clear(); + state.order.0.clear(); + *state.selection = Selection::Empty; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use crate::engine::command::fixture::Fixture; + + use super::*; + + #[test] + fn test_delete_all() { + let mut fx = Fixture::default(); + assert_eq!( + DeleteAll.apply(&mut fx.state()), + Err(CommandError::NothingToDo) + ); + crate::New { name: "a" }.apply(&mut fx.state()).unwrap(); + crate::New { name: "b" }.apply(&mut fx.state()).unwrap(); + assert!(DeleteAll.apply(&mut fx.state()).is_ok()); + assert!(fx.files.is_empty()); + assert!(fx.order.0.is_empty()); + assert!(matches!(fx.selection, Selection::Empty)); + } +} diff --git a/gpx-rs/engine/src/engine/command/file/duplicate.rs b/gpx-rs/engine/src/engine/command/file/duplicate.rs new file mode 100644 index 000000000..846932339 --- /dev/null +++ b/gpx-rs/engine/src/engine/command/file/duplicate.rs @@ -0,0 +1,11 @@ +use crate::{Apply, CommandError, State}; + +#[derive(Debug)] +pub struct Duplicate; + +impl Apply for Duplicate { + fn apply(self, _state: &mut State) -> Result<(), CommandError> { + // TODO + Err(CommandError::NotImplemented("duplicate")) + } +} diff --git a/gpx-rs/engine/src/engine/command/file/load.rs b/gpx-rs/engine/src/engine/command/file/load.rs new file mode 100644 index 000000000..141257c63 --- /dev/null +++ b/gpx-rs/engine/src/engine/command/file/load.rs @@ -0,0 +1,48 @@ +use crate::{Apply, CommandError, State, parse, produce}; + +#[derive(Debug)] +pub struct Load<'a> { + pub data: &'a [u8], +} + +impl Apply for Load<'_> { + fn apply(self, state: &mut State) -> Result<(), CommandError> { + let file = parse(self.data).map_err(|err| CommandError::InvalidData(err.to_string()))?; + produce(state, |_| vec![file]); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use crate::engine::command::fixture::Fixture; + + use super::*; + + #[test] + fn test_load() { + let mut fx = Fixture::default(); + let data = std::fs::read("data/with_tracks.gpx").unwrap(); + assert!(Load { data: &data }.apply(&mut fx.state()).is_ok()); + assert_eq!(fx.files.len(), 1); + let file = fx.files.values().next().unwrap(); + assert!(!file.trk.is_empty()); + assert_eq!(fx.selected_files(), [file.id].into()); + } + + #[test] + fn test_load_invalid_file_changes_nothing() { + let mut fx = Fixture::default(); + crate::New { name: "keep" }.apply(&mut fx.state()).unwrap(); + let selected = fx.selected_files(); + + let result = Load { + data: b"", + } + .apply(&mut fx.state()); + assert!(matches!(result, Err(CommandError::InvalidData(_)))); + assert_eq!(fx.files.len(), 1); + assert_eq!(fx.order.0.len(), 1); + assert_eq!(fx.selected_files(), selected); + } +} diff --git a/gpx-rs/engine/src/engine/command/file/mod.rs b/gpx-rs/engine/src/engine/command/file/mod.rs new file mode 100644 index 000000000..dffaa5e05 --- /dev/null +++ b/gpx-rs/engine/src/engine/command/file/mod.rs @@ -0,0 +1,11 @@ +mod delete; +mod delete_all; +mod duplicate; +mod load; +mod new; + +pub use delete::*; +pub use delete_all::*; +pub use duplicate::*; +pub use load::*; +pub use new::*; diff --git a/gpx-rs/engine/src/engine/command/file/new.rs b/gpx-rs/engine/src/engine/command/file/new.rs new file mode 100644 index 000000000..0be364e30 --- /dev/null +++ b/gpx-rs/engine/src/engine/command/file/new.rs @@ -0,0 +1,36 @@ +use crate::{Apply, CommandError, File, State, produce}; + +#[derive(Debug)] +pub struct New<'a> { + pub name: &'a str, +} + +impl Apply for New<'_> { + fn apply(self, state: &mut State) -> Result<(), CommandError> { + produce(state, |_| { + let mut file = File::default(); + file.info.name = self.name.to_owned(); + vec![file] + }); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use crate::engine::command::fixture::Fixture; + + use super::*; + + #[test] + fn test_new() { + let mut fx = Fixture::default(); + assert!(New { name: "new" }.apply(&mut fx.state()).is_ok()); + assert_eq!(fx.files.len(), 1); + let file = fx.files.values().next().unwrap(); + assert_eq!(file.info.name, "new"); + assert!(file.trk.is_empty()); + assert_eq!(fx.selected_files(), [file.id].into()); + assert_eq!(fx.order.0, vec![file.id]); + } +} diff --git a/gpx-rs/engine/src/engine/command/fixture.rs b/gpx-rs/engine/src/engine/command/fixture.rs new file mode 100644 index 000000000..39857f258 --- /dev/null +++ b/gpx-rs/engine/src/engine/command/fixture.rs @@ -0,0 +1,30 @@ +//! Shared helpers for the tests of the commands. + +use std::collections::HashSet; + +use crate::{FileId, FileOrder, Selection, StackEntry, State}; + +#[derive(Default)] +pub struct Fixture { + pub files: StackEntry, + pub selection: Selection, + pub order: FileOrder, +} + +impl Fixture { + pub fn state(&mut self) -> State<'_> { + State { + files: &mut self.files, + selection: &mut self.selection, + order: &mut self.order, + } + } + + /// Ids of the selected files, empty unless the selection is a file selection. + pub fn selected_files(&self) -> HashSet { + match &self.selection { + Selection::File { file_ids } => file_ids.clone(), + _ => HashSet::new(), + } + } +} diff --git a/gpx-rs/engine/src/engine/command/mod.rs b/gpx-rs/engine/src/engine/command/mod.rs index 525bd6863..ad2d90134 100644 --- a/gpx-rs/engine/src/engine/command/mod.rs +++ b/gpx-rs/engine/src/engine/command/mod.rs @@ -1,7 +1,15 @@ -mod applier; +mod apply; mod command; +mod edit; +mod file; +#[cfg(test)] +mod fixture; mod pattern; +mod tools; -pub use applier::*; +pub use apply::*; pub use command::*; +pub use edit::*; +pub use file::*; pub use pattern::*; +pub use tools::*; diff --git a/gpx-rs/engine/src/engine/command/pattern.rs b/gpx-rs/engine/src/engine/command/pattern.rs deleted file mode 100644 index 527ed73cc..000000000 --- a/gpx-rs/engine/src/engine/command/pattern.rs +++ /dev/null @@ -1,179 +0,0 @@ -use std::{ - collections::{HashSet, hash_map::Entry}, - rc::Rc, -}; - -use crate::{File, FileId, Selection, State}; - -pub fn produce(state: &mut State, f: F) -where - F: FnOnce(&State) -> Vec, -{ - *state.selection = Selection::Empty; - for file in f(state).drain(..) { - let file_id = file.id; - state.files.insert(file_id, Rc::new(file)); - state.order.0.push(file_id); - if matches!(*state.selection, Selection::Empty) { - let mut file_ids: HashSet = Default::default(); - file_ids.insert(file_id); - *state.selection = Selection::File { file_ids }; - } - } -} - -pub fn update_each_selected_file(state: &mut State, f: &mut F) -where - F: FnMut(&File) -> File, -{ - if let Selection::File { file_ids } = state.selection { - for file_id in file_ids.iter() { - if let Entry::Occupied(mut e) = state.files.entry(*file_id) { - let file = e.get(); - let updated = Rc::new(f(file.as_ref())); - e.insert(updated); - } - } - } -} - -#[cfg(test)] -mod tests { - use crate::FileOrder; - - use super::*; - - fn named(name: &str) -> File { - let mut file = File::default(); - file.info.name = name.to_string(); - file - } - - struct Fixture { - files: crate::StackEntry, - selection: Selection, - order: FileOrder, - } - - impl Fixture { - fn new() -> Self { - Self { - files: Default::default(), - selection: Selection::Empty, - order: FileOrder::default(), - } - } - - fn state(&mut self) -> State<'_> { - State { - files: &mut self.files, - selection: &mut self.selection, - order: &mut self.order, - } - } - - fn selected(&self) -> HashSet { - match &self.selection { - Selection::File { file_ids } => file_ids.clone(), - _ => HashSet::new(), - } - } - } - - #[test] - fn test_produce_adds_orders_and_selects_first() { - let mut fx = Fixture::new(); - let (a, b) = (named("a"), named("b")); - let (id_a, id_b) = (a.id, b.id); - produce(&mut fx.state(), |_| vec![a, b]); - - assert_eq!(fx.files.len(), 2); - assert_eq!(fx.order.0, vec![id_a, id_b]); - assert_eq!(fx.selected(), HashSet::from([id_a])); - } - - #[test] - fn test_produce_replaces_selection_and_keeps_order() { - let mut fx = Fixture::new(); - let a = named("a"); - let id_a = a.id; - produce(&mut fx.state(), |_| vec![a]); - let b = named("b"); - let id_b = b.id; - produce(&mut fx.state(), |_| vec![b]); - - assert_eq!(fx.order.0, vec![id_a, id_b]); - assert_eq!(fx.selected(), HashSet::from([id_b])); - } - - #[test] - fn test_produce_nothing_clears_selection() { - let mut fx = Fixture::new(); - let a = named("a"); - produce(&mut fx.state(), |_| vec![a]); - produce(&mut fx.state(), |_| vec![]); - assert!(matches!(fx.selection, Selection::Empty)); - assert_eq!(fx.files.len(), 1); - } - - #[test] - fn test_produce_sees_current_state() { - let mut fx = Fixture::new(); - let a = named("a"); - produce(&mut fx.state(), |_| vec![a]); - produce(&mut fx.state(), |state| { - assert_eq!(state.files.len(), 1); - vec![] - }); - } - - #[test] - fn test_update_each_selected_file_only_touches_selection() { - let mut fx = Fixture::new(); - let (a, b, c) = (named("a"), named("b"), named("c")); - let ids = [a.id, b.id, c.id]; - for file in [a, b, c] { - fx.files.insert(file.id, Rc::new(file)); - } - let untouched = fx.files[&ids[2]].clone(); - fx.selection = Selection::File { - file_ids: HashSet::from([ids[0], ids[1]]), - }; - - let mut calls = 0; - update_each_selected_file(&mut fx.state(), &mut |file| { - calls += 1; - let mut next = file.clone(); - next.info.name.push('!'); - next - }); - - assert_eq!(calls, 2); - assert_eq!(fx.files[&ids[0]].info.name, "a!"); - assert_eq!(fx.files[&ids[1]].info.name, "b!"); - assert!(Rc::ptr_eq(&fx.files[&ids[2]], &untouched)); - } - - #[test] - fn test_update_each_selected_file_ignores_missing_and_other_selections() { - let mut fx = Fixture::new(); - let a = named("a"); - let id = a.id; - fx.files.insert(id, Rc::new(a)); - - let mut calls = 0; - let mut count = |file: &File| { - calls += 1; - file.clone() - }; - fx.selection = Selection::File { - file_ids: HashSet::from([FileId::default()]), - }; - update_each_selected_file(&mut fx.state(), &mut count); - fx.selection = Selection::Waypoints { file_id: id }; - update_each_selected_file(&mut fx.state(), &mut count); - fx.selection = Selection::Empty; - update_each_selected_file(&mut fx.state(), &mut count); - assert_eq!(calls, 0); - } -} diff --git a/gpx-rs/engine/src/engine/command/pattern/mod.rs b/gpx-rs/engine/src/engine/command/pattern/mod.rs new file mode 100644 index 000000000..2243f95cb --- /dev/null +++ b/gpx-rs/engine/src/engine/command/pattern/mod.rs @@ -0,0 +1,5 @@ +mod produce; +mod update_selected; + +pub use produce::*; +pub use update_selected::*; diff --git a/gpx-rs/engine/src/engine/command/pattern/produce.rs b/gpx-rs/engine/src/engine/command/pattern/produce.rs new file mode 100644 index 000000000..414332fc2 --- /dev/null +++ b/gpx-rs/engine/src/engine/command/pattern/produce.rs @@ -0,0 +1,81 @@ +use std::rc::Rc; + +use crate::{File, Selection, State}; + +/// Pure producer: adds the files returned by `f` to the state and selects the first one. +pub fn produce(state: &mut State, f: F) +where + F: FnOnce(&State) -> Vec, +{ + *state.selection = Selection::Empty; + for file in f(state) { + let file_id = file.id; + state.files.insert(file_id, Rc::new(file)); + state.order.0.push(file_id); + if matches!(*state.selection, Selection::Empty) { + *state.selection = Selection::File { + file_ids: [file_id].into(), + }; + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use crate::engine::command::fixture::Fixture; + + use super::*; + + fn named(name: &str) -> File { + let mut file = File::default(); + file.info.name = name.to_string(); + file + } + + #[test] + fn test_produce_adds_orders_and_selects_first() { + let mut fx = Fixture::default(); + let (a, b) = (named("a"), named("b")); + let (id_a, id_b) = (a.id, b.id); + produce(&mut fx.state(), |_| vec![a, b]); + + assert_eq!(fx.files.len(), 2); + assert_eq!(fx.order.0, vec![id_a, id_b]); + assert_eq!(fx.selected_files(), HashSet::from([id_a])); + } + + #[test] + fn test_produce_replaces_selection_and_keeps_order() { + let mut fx = Fixture::default(); + let a = named("a"); + let id_a = a.id; + produce(&mut fx.state(), |_| vec![a]); + let b = named("b"); + let id_b = b.id; + produce(&mut fx.state(), |_| vec![b]); + + assert_eq!(fx.order.0, vec![id_a, id_b]); + assert_eq!(fx.selected_files(), HashSet::from([id_b])); + } + + #[test] + fn test_produce_nothing_clears_selection() { + let mut fx = Fixture::default(); + produce(&mut fx.state(), |_| vec![named("a")]); + produce(&mut fx.state(), |_| vec![]); + assert!(matches!(fx.selection, Selection::Empty)); + assert_eq!(fx.files.len(), 1); + } + + #[test] + fn test_produce_sees_current_state() { + let mut fx = Fixture::default(); + produce(&mut fx.state(), |_| vec![named("a")]); + produce(&mut fx.state(), |state| { + assert_eq!(state.files.len(), 1); + vec![] + }); + } +} diff --git a/gpx-rs/engine/src/engine/command/pattern/update_selected.rs b/gpx-rs/engine/src/engine/command/pattern/update_selected.rs new file mode 100644 index 000000000..5c54b6a1d --- /dev/null +++ b/gpx-rs/engine/src/engine/command/pattern/update_selected.rs @@ -0,0 +1,266 @@ +use std::rc::Rc; + +use crate::{File, FileId, Selection, StackEntry, State, Track, TrackSegment}; + +/// What an [`Editor`] hook did to the element it was given. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Edit { + Unchanged, + Changed, +} + +impl Edit { + fn merge(self, other: Edit) -> Edit { + if self == Edit::Changed || other == Edit::Changed { + Edit::Changed + } else { + Edit::Unchanged + } + } +} + +/// Independent edit of each selected element, without needing the rest of the state. +/// +/// A selected file, track or segment is passed to the hook of its own level. By default a +/// file is forwarded to its tracks and a track to its segments, so an editor only overrides +/// the level(s) it cares about (metadata: file and track, style: track, reverse: segment). +pub trait Editor { + fn file(&mut self, file: &mut File) -> Edit { + edit_each(&mut file.trk, |trk| self.track(trk)) + } + + fn track(&mut self, track: &mut Track) -> Edit { + edit_each(&mut track.trkseg, |seg| edit_segment(self, seg)) + } + + fn segment(&mut self, _segment: &mut TrackSegment) -> Edit { + Edit::Unchanged + } +} + +/// Calls the segment hook and gives the segment a new revision id if it changed, so that +/// derived data (statistics) is recomputed. +fn edit_segment(editor: &mut E, segment: &mut TrackSegment) -> Edit { + let edit = editor.segment(segment); + if edit == Edit::Changed { + segment.rev_id = Default::default(); + } + edit +} + +fn edit_each(items: &mut [T], f: impl FnMut(&mut T) -> Edit) -> Edit { + edit_where(items, |_| true, f) +} + +fn edit_where( + items: &mut [T], + filter: impl Fn(&T) -> bool, + mut f: impl FnMut(&mut T) -> Edit, +) -> Edit { + items + .iter_mut() + .filter(|item| filter(item)) + .fold(Edit::Unchanged, |acc, item| acc.merge(f(item))) +} + +/// Edits a copy of the file (cheap, track points are shared chunks) and stores it back as a +/// new snapshot only if something changed. +fn update_file(files: &mut StackEntry, id: FileId, f: impl FnOnce(&mut File) -> Edit) { + let Some(current) = files.get(&id) else { + return; + }; + let mut file = (**current).clone(); + if f(&mut file) == Edit::Changed { + files.insert(id, Rc::new(file)); + } +} + +/// Applies the editor to every selected file, track or segment. +pub fn update_selected(state: &mut State, editor: &mut E) { + match &*state.selection { + Selection::File { file_ids } => { + for id in file_ids { + update_file(state.files, *id, |file| editor.file(file)); + } + } + Selection::Track { file_id, trk_ids } => { + update_file(state.files, *file_id, |file| { + edit_where( + &mut file.trk, + |trk| trk_ids.contains(&trk.id), + |trk| editor.track(trk), + ) + }); + } + Selection::TrackSegment { + file_id, + trk_id, + trkseg_ids, + } => { + update_file(state.files, *file_id, |file| { + edit_where( + &mut file.trk, + |trk| trk.id == *trk_id, + |trk| { + edit_where( + &mut trk.trkseg, + |seg| trkseg_ids.contains(&seg.id), + |seg| edit_segment(editor, seg), + ) + }, + ) + }); + } + // TODO waypoint-level hooks + Selection::Empty | Selection::Waypoints { .. } | Selection::Waypoint { .. } => {} + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use crate::engine::command::fixture::Fixture; + + use super::*; + + struct Rename; + + impl Editor for Rename { + fn file(&mut self, file: &mut File) -> Edit { + file.info.name.push('!'); + Edit::Changed + } + + fn track(&mut self, track: &mut Track) -> Edit { + track.info.name = Some("track".to_string()); + Edit::Changed + } + + fn segment(&mut self, _: &mut TrackSegment) -> Edit { + Edit::Changed + } + } + + struct Nothing; + impl Editor for Nothing {} + + fn fixture_with_tracks() -> (Fixture, FileId) { + let mut fx = Fixture::default(); + let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap(); + let file = Rc::new(crate::parse(&data).unwrap()); + let id = file.id; + fx.files.insert(id, file); + (fx, id) + } + + #[test] + fn test_only_selected_files_are_updated() { + let mut fx = Fixture::default(); + let (a, b, c) = (File::default(), File::default(), File::default()); + let ids = [a.id, b.id, c.id]; + for file in [a, b, c] { + fx.files.insert(file.id, Rc::new(file)); + } + let untouched = fx.files[&ids[2]].clone(); + fx.selection = Selection::File { + file_ids: HashSet::from([ids[0], ids[1]]), + }; + + update_selected(&mut fx.state(), &mut Rename); + + assert_eq!(fx.files[&ids[0]].info.name, "!"); + assert_eq!(fx.files[&ids[1]].info.name, "!"); + assert!(Rc::ptr_eq(&fx.files[&ids[2]], &untouched)); + } + + #[test] + fn test_unchanged_file_keeps_its_snapshot() { + let (mut fx, id) = fixture_with_tracks(); + fx.selection = Selection::File { + file_ids: HashSet::from([id]), + }; + let before = fx.files[&id].clone(); + update_selected(&mut fx.state(), &mut Nothing); + assert!(Rc::ptr_eq(&fx.files[&id], &before)); + } + + #[test] + fn test_track_selection_uses_track_hook() { + let (mut fx, id) = fixture_with_tracks(); + let trk_id = fx.files[&id].trk[0].id; + fx.selection = Selection::Track { + file_id: id, + trk_ids: HashSet::from([trk_id]), + }; + update_selected(&mut fx.state(), &mut Rename); + + let file = &fx.files[&id]; + assert_eq!(file.info.name, "with_tracks_and_segments"); + assert_eq!(file.trk[0].info.name.as_deref(), Some("track")); + assert_ne!(file.trk[1].info.name.as_deref(), Some("track")); + } + + #[test] + fn test_segment_selection_bumps_revision_id() { + let (mut fx, id) = fixture_with_tracks(); + let before = fx.files[&id].clone(); + let trk = &before.trk[0]; + fx.selection = Selection::TrackSegment { + file_id: id, + trk_id: trk.id, + trkseg_ids: HashSet::from([trk.trkseg[0].id]), + }; + update_selected(&mut fx.state(), &mut Rename); + + let after = &fx.files[&id]; + assert_ne!(after.trk[0].trkseg[0].rev_id, trk.trkseg[0].rev_id); + assert_eq!(after.trk[0].trkseg[1].rev_id, trk.trkseg[1].rev_id); + assert_eq!( + after.trk[1].trkseg[0].rev_id, + before.trk[1].trkseg[0].rev_id + ); + } + + #[test] + fn test_file_selection_cascades_to_segments() { + struct SegmentsOnly; + impl Editor for SegmentsOnly { + fn segment(&mut self, _: &mut TrackSegment) -> Edit { + Edit::Changed + } + } + let (mut fx, id) = fixture_with_tracks(); + let before = fx.files[&id].clone(); + fx.selection = Selection::File { + file_ids: HashSet::from([id]), + }; + update_selected(&mut fx.state(), &mut SegmentsOnly); + for (b, a) in before.trk.iter().zip(fx.files[&id].trk.iter()) { + for (b, a) in b.trkseg.iter().zip(a.trkseg.iter()) { + assert_ne!(b.rev_id, a.rev_id); + } + } + } + + #[test] + fn test_missing_files_and_other_selections_are_ignored() { + let mut fx = Fixture::default(); + let file = File::default(); + let id = file.id; + fx.files.insert(id, Rc::new(file)); + let before = fx.files[&id].clone(); + + for selection in [ + Selection::File { + file_ids: HashSet::from([FileId::default()]), + }, + Selection::Waypoints { file_id: id }, + Selection::Empty, + ] { + fx.selection = selection; + update_selected(&mut fx.state(), &mut Rename); + assert!(Rc::ptr_eq(&fx.files[&id], &before)); + } + } +} diff --git a/gpx-rs/engine/src/engine/command/tools/append.rs b/gpx-rs/engine/src/engine/command/tools/append.rs new file mode 100644 index 000000000..91b86d829 --- /dev/null +++ b/gpx-rs/engine/src/engine/command/tools/append.rs @@ -0,0 +1,15 @@ +use crate::{Apply, CommandError, State}; + +#[derive(Debug)] +pub struct Append<'a> { + pub lng: &'a [f64], + pub lat: &'a [f64], + pub ele: &'a [f64], +} + +impl Apply for Append<'_> { + fn apply(self, _state: &mut State) -> Result<(), CommandError> { + // TODO + Err(CommandError::NotImplemented("append")) + } +} diff --git a/gpx-rs/engine/src/engine/command/tools/clean.rs b/gpx-rs/engine/src/engine/command/tools/clean.rs new file mode 100644 index 000000000..33c1d2adc --- /dev/null +++ b/gpx-rs/engine/src/engine/command/tools/clean.rs @@ -0,0 +1,22 @@ +use crate::{Apply, CommandError, LngLatBounds, State}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CleanType { + Inside, + Outside, +} + +#[derive(Debug)] +pub struct Clean { + pub bounds: LngLatBounds, + pub type_: CleanType, + pub trkpt: bool, + pub wpt: bool, +} + +impl Apply for Clean { + fn apply(self, _state: &mut State) -> Result<(), CommandError> { + // TODO + Err(CommandError::NotImplemented("clean")) + } +} diff --git a/gpx-rs/engine/src/engine/command/tools/crop.rs b/gpx-rs/engine/src/engine/command/tools/crop.rs new file mode 100644 index 000000000..39230890e --- /dev/null +++ b/gpx-rs/engine/src/engine/command/tools/crop.rs @@ -0,0 +1,14 @@ +use crate::{Apply, CommandError, State}; + +#[derive(Debug)] +pub struct Crop { + pub start: u32, + pub end: u32, +} + +impl Apply for Crop { + fn apply(self, _state: &mut State) -> Result<(), CommandError> { + // TODO + Err(CommandError::NotImplemented("crop")) + } +} diff --git a/gpx-rs/engine/src/engine/command/tools/elevation.rs b/gpx-rs/engine/src/engine/command/tools/elevation.rs new file mode 100644 index 000000000..e118d4c9e --- /dev/null +++ b/gpx-rs/engine/src/engine/command/tools/elevation.rs @@ -0,0 +1,13 @@ +use crate::{Apply, CommandError, State}; + +#[derive(Debug)] +pub struct Elevation<'a> { + pub ele: &'a [f64], +} + +impl Apply for Elevation<'_> { + fn apply(self, _state: &mut State) -> Result<(), CommandError> { + // TODO + Err(CommandError::NotImplemented("elevation")) + } +} diff --git a/gpx-rs/engine/src/engine/command/tools/extract.rs b/gpx-rs/engine/src/engine/command/tools/extract.rs new file mode 100644 index 000000000..a04273e70 --- /dev/null +++ b/gpx-rs/engine/src/engine/command/tools/extract.rs @@ -0,0 +1,11 @@ +use crate::{Apply, CommandError, State}; + +#[derive(Debug)] +pub struct Extract; + +impl Apply for Extract { + fn apply(self, _state: &mut State) -> Result<(), CommandError> { + // TODO + Err(CommandError::NotImplemented("extract")) + } +} diff --git a/gpx-rs/engine/src/engine/command/tools/merge.rs b/gpx-rs/engine/src/engine/command/tools/merge.rs new file mode 100644 index 000000000..8c659df65 --- /dev/null +++ b/gpx-rs/engine/src/engine/command/tools/merge.rs @@ -0,0 +1,19 @@ +use crate::{Apply, CommandError, State}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MergeType { + Connect, + Group, +} + +#[derive(Debug)] +pub struct Merge { + pub type_: MergeType, +} + +impl Apply for Merge { + fn apply(self, _state: &mut State) -> Result<(), CommandError> { + // TODO + Err(CommandError::NotImplemented("merge")) + } +} diff --git a/gpx-rs/engine/src/engine/command/tools/mod.rs b/gpx-rs/engine/src/engine/command/tools/mod.rs new file mode 100644 index 000000000..48550ddec --- /dev/null +++ b/gpx-rs/engine/src/engine/command/tools/mod.rs @@ -0,0 +1,25 @@ +mod append; +mod clean; +mod crop; +mod elevation; +mod extract; +mod merge; +mod move_waypoint; +mod new_waypoint; +mod replace; +mod reverse; +mod split; +mod time; + +pub use append::*; +pub use clean::*; +pub use crop::*; +pub use elevation::*; +pub use extract::*; +pub use merge::*; +pub use move_waypoint::*; +pub use new_waypoint::*; +pub use replace::*; +pub use reverse::*; +pub use split::*; +pub use time::*; diff --git a/gpx-rs/engine/src/engine/command/tools/move_waypoint.rs b/gpx-rs/engine/src/engine/command/tools/move_waypoint.rs new file mode 100644 index 000000000..99541109f --- /dev/null +++ b/gpx-rs/engine/src/engine/command/tools/move_waypoint.rs @@ -0,0 +1,15 @@ +use crate::{Apply, CommandError, State}; + +#[derive(Debug)] +pub struct MoveWaypoint { + pub lng: f64, + pub lat: f64, + pub ele: f64, +} + +impl Apply for MoveWaypoint { + fn apply(self, _state: &mut State) -> Result<(), CommandError> { + // TODO + Err(CommandError::NotImplemented("move waypoint")) + } +} diff --git a/gpx-rs/engine/src/engine/command/tools/new_waypoint.rs b/gpx-rs/engine/src/engine/command/tools/new_waypoint.rs new file mode 100644 index 000000000..c817076f4 --- /dev/null +++ b/gpx-rs/engine/src/engine/command/tools/new_waypoint.rs @@ -0,0 +1,19 @@ +use crate::{Apply, CommandError, State}; + +#[derive(Debug)] +pub struct NewWaypoint<'a> { + pub lng: f64, + pub lat: f64, + pub ele: f64, + pub name: &'a str, + pub desc: &'a str, + pub icon: &'a str, + pub link: &'a str, +} + +impl Apply for NewWaypoint<'_> { + fn apply(self, _state: &mut State) -> Result<(), CommandError> { + // TODO + Err(CommandError::NotImplemented("new waypoint")) + } +} diff --git a/gpx-rs/engine/src/engine/command/tools/replace.rs b/gpx-rs/engine/src/engine/command/tools/replace.rs new file mode 100644 index 000000000..430993f22 --- /dev/null +++ b/gpx-rs/engine/src/engine/command/tools/replace.rs @@ -0,0 +1,17 @@ +use crate::{Apply, CommandError, State}; + +#[derive(Debug)] +pub struct Replace<'a> { + pub start: u32, + pub end: u32, + pub lng: &'a [f64], + pub lat: &'a [f64], + pub ele: &'a [f64], +} + +impl Apply for Replace<'_> { + fn apply(self, _state: &mut State) -> Result<(), CommandError> { + // TODO + Err(CommandError::NotImplemented("replace")) + } +} diff --git a/gpx-rs/engine/src/engine/command/tools/reverse.rs b/gpx-rs/engine/src/engine/command/tools/reverse.rs new file mode 100644 index 000000000..ff06eb5a5 --- /dev/null +++ b/gpx-rs/engine/src/engine/command/tools/reverse.rs @@ -0,0 +1,11 @@ +use crate::{Apply, CommandError, State}; + +#[derive(Debug)] +pub struct Reverse; + +impl Apply for Reverse { + fn apply(self, _state: &mut State) -> Result<(), CommandError> { + // TODO + Err(CommandError::NotImplemented("reverse")) + } +} diff --git a/gpx-rs/engine/src/engine/command/tools/split.rs b/gpx-rs/engine/src/engine/command/tools/split.rs new file mode 100644 index 000000000..34a648b73 --- /dev/null +++ b/gpx-rs/engine/src/engine/command/tools/split.rs @@ -0,0 +1,13 @@ +use crate::{Apply, CommandError, State}; + +#[derive(Debug)] +pub struct Split { + pub at: u32, +} + +impl Apply for Split { + fn apply(self, _state: &mut State) -> Result<(), CommandError> { + // TODO + Err(CommandError::NotImplemented("split")) + } +} diff --git a/gpx-rs/engine/src/engine/command/tools/time.rs b/gpx-rs/engine/src/engine/command/tools/time.rs new file mode 100644 index 000000000..722072dba --- /dev/null +++ b/gpx-rs/engine/src/engine/command/tools/time.rs @@ -0,0 +1,11 @@ +use crate::{Apply, CommandError, State}; + +#[derive(Debug)] +pub struct Time; + +impl Apply for Time { + fn apply(self, _state: &mut State) -> Result<(), CommandError> { + // TODO + Err(CommandError::NotImplemented("time")) + } +} diff --git a/gpx-rs/engine/src/engine/engine.rs b/gpx-rs/engine/src/engine/engine.rs index c356ddaeb..07f3e3eb6 100644 --- a/gpx-rs/engine/src/engine/engine.rs +++ b/gpx-rs/engine/src/engine/engine.rs @@ -1,8 +1,10 @@ // TODO license in every file +use std::collections::HashSet; + use crate::{ - Command, FileOrder, Selection, Stack, State, StatisticsBuffer, StatisticsCache, create_file, - load_file, update_metadata, + Action, Apply, Command, FileId, FileOrder, Selection, Stack, State, StatisticsBuffer, + StatisticsCache, }; #[derive(Debug, Default)] @@ -19,97 +21,116 @@ impl Engine { &self.statistics_buffer } - // pub fn delete_file(&mut self, id: &[u8]) -> bool { - // self.event_loop(|entry| { - // if let Ok(id) = Uuid::from_slice(id) { - // let id = FileId(id); - // entry.remove(&id).is_some() - // } else { - // false - // } - // }) - // } - - /// Single entry point for every user action coming from the frontend. - pub fn execute(&mut self, command: Command) -> bool { - if let Some(diff) = self.stack.create_and_push_next(|files| { - let mut state = State { - files, - selection: &mut self.selection, - order: &mut self.order, - }; - match command { - Command::New { name } => create_file(&mut state, name), - Command::Load { data } => load_file(&mut state, data), - Command::Metadata { name, desc } => update_metadata(&mut state, name, desc), - Command::Delete - | Command::DeleteAll - | Command::Duplicate - | Command::Style { .. } - | Command::NewTrack - | Command::NewTrackSegment - | Command::Reverse - | Command::Append { .. } - | Command::Replace { .. } - | Command::NewWaypoint { .. } - | Command::MoveWaypoint { .. } - | Command::Crop { .. } - | Command::Split { .. } - | Command::Time - | Command::Merge { .. } - | Command::Extract - | Command::Elevation { .. } - | Command::Clean { .. } - | Command::Undo - | Command::Redo - | Command::Select { .. } - | Command::AddSelect { .. } - | Command::SelectAll => todo!("command not implemented yet"), + /// Single entry point for every action coming from the frontend. Returns whether anything + /// changed. + pub fn execute(&mut self, action: Action) -> bool { + let changed = match action { + Action::Edit(command) => self.edit(command), + Action::Undo => self.stack.undo().is_some(), + Action::Redo => self.stack.redo().is_some(), + Action::Select { file_ids } => { + self.selection = Selection::File { + file_ids: file_ids.into_iter().collect(), + }; + true } - }) { - self.statistics_cache.update(self.stack.current()); - self.statistics_buffer.update( - &self - .statistics_cache - .get(self.stack.current(), &self.selection), - ); - true - } else { - false + 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(), + } + } + } + true + } + Action::SelectAll => { + self.selection = Selection::File { + file_ids: self.order.0.iter().copied().collect(), + }; + true + } + }; + if changed { + self.refresh(); } + changed + } + + fn edit(&mut self, command: Command) -> bool { + self.stack + .create_and_push_next(|files| { + let mut state = State { + files, + selection: &mut self.selection, + order: &mut self.order, + }; + command.apply(&mut state).map_err(|err| err.to_string()) + }) + .is_some() + } + + /// Brings everything derived from the files back in line with the current stack entry. + fn refresh(&mut self) { + let current = self.stack.current(); + match current { + Some(files) => { + self.order.0.retain(|id| files.contains_key(id)); + let known: HashSet = self.order.0.iter().copied().collect(); + self.order + .0 + .extend(files.keys().filter(|id| !known.contains(id)).copied()); + self.selection.retain_existing(files); + } + None => { + self.order.0.clear(); + self.selection = Selection::Empty; + } + } + self.statistics_cache.update(current); + self.statistics_buffer + .update(&self.statistics_cache.get(current, &self.selection)); } } #[cfg(test)] mod tests { - use std::{fs::File, io::Read}; + use crate::{Load, Metadata, New, NewTrack, Style}; use super::*; - #[test] - fn test_load_file() { - let mut engine = Engine::default(); + fn edit(engine: &mut Engine, command: Command) -> bool { + engine.execute(Action::Edit(command)) + } - let mut f = File::open("data/simple.gpx").unwrap(); - let mut data = String::new(); - let _ = f.read_to_string(&mut data); - - engine.execute(Command::Load { - data: data.as_bytes(), - }); - - assert_eq!(engine.statistics_buffer.total_distance.len(), 80); + fn new(engine: &mut Engine, name: &str) -> bool { + edit(engine, Command::New(New { name })) } fn load(engine: &mut Engine, path: &str) -> bool { let data = std::fs::read(path).unwrap(); - engine.execute(Command::Load { data: &data }) + edit(engine, Command::Load(Load { data: &data })) + } + + fn selected(engine: &Engine) -> Vec { + match &engine.selection { + Selection::File { file_ids } => file_ids.iter().copied().collect(), + _ => vec![], + } + } + + #[test] + fn test_load_file() { + let mut engine = Engine::default(); + assert!(load(&mut engine, "data/simple.gpx")); + assert_eq!(engine.statistics_buffer.total_distance.len(), 80); } #[test] fn test_new_file_has_no_statistics() { let mut engine = Engine::default(); - assert!(engine.execute(Command::New { name: "empty" })); + assert!(new(&mut engine, "empty")); assert_eq!(engine.stack.current().unwrap().len(), 1); assert_eq!(engine.order.0.len(), 1); assert!(engine.statistics().total_distance.is_empty()); @@ -123,7 +144,7 @@ mod tests { assert!(n > 0); // a newly created (empty) file becomes the selection - assert!(engine.execute(Command::New { name: "empty" })); + assert!(new(&mut engine, "empty")); assert!(engine.statistics().total_distance.is_empty()); assert_eq!(engine.stack.current().unwrap().len(), 2); @@ -135,39 +156,142 @@ mod tests { #[test] fn test_invalid_load_is_rejected() { let mut engine = Engine::default(); - assert!(!engine.execute(Command::Load { - data: b"" - })); + assert!(!edit( + &mut engine, + Command::Load(Load { + data: b"" + }) + )); assert!(engine.stack.current().is_none()); assert!(!engine.stack.can_undo()); assert!(engine.order.0.is_empty()); } + #[test] + fn test_not_implemented_command_is_rejected() { + let mut engine = Engine::default(); + new(&mut engine, "file"); + assert!(!edit(&mut engine, Command::Reverse(crate::Reverse))); + assert!(!engine.stack.can_redo()); + assert_eq!(engine.stack.current().unwrap().len(), 1); + } + #[test] fn test_metadata_renames_selected_file() { let mut engine = Engine::default(); - engine.execute(Command::New { name: "before" }); - assert!(engine.execute(Command::Metadata { - name: "after", - desc: "about", - })); - let state = engine.stack.current().unwrap(); - let file = state.values().next().unwrap(); + new(&mut engine, "before"); + assert!(edit( + &mut engine, + Command::Metadata(Metadata { + name: "after", + desc: "about", + }) + )); + let file = engine.stack.current().unwrap().values().next().unwrap(); assert_eq!(file.info.name, "after"); assert_eq!(file.info.desc.as_deref(), Some("about")); - assert!(engine.stack.can_undo()); } #[test] fn test_metadata_without_selection_does_not_modify_files() { let mut engine = Engine::default(); - engine.execute(Command::New { name: "file" }); + new(&mut engine, "file"); engine.selection = Selection::Empty; - engine.execute(Command::Metadata { - name: "renamed", - desc: "", - }); - let state = engine.stack.current().unwrap(); - assert_eq!(state.values().next().unwrap().info.name, "file"); + edit( + &mut engine, + Command::Metadata(Metadata { + name: "renamed", + desc: "", + }), + ); + let file = engine.stack.current().unwrap().values().next().unwrap(); + assert_eq!(file.info.name, "file"); + } + + #[test] + fn test_edit_changes_statistics_when_segments_change() { + let mut engine = Engine::default(); + load(&mut engine, "data/simple.gpx"); + let before = engine + .statistics_cache + .get(engine.stack.current(), &engine.selection)[0] as *const _; + edit(&mut engine, Command::NewTrack(NewTrack)); + edit( + &mut engine, + Command::Style(Style { + color: Some("ff0000"), + opacity: None, + width: None, + }), + ); + // styling does not touch the segments: their statistics are reused + let after = engine + .statistics_cache + .get(engine.stack.current(), &engine.selection)[0] as *const _; + assert!(std::ptr::eq(before, after)); + } + + #[test] + fn test_undo_redo_keep_selection_and_order_consistent() { + let mut engine = Engine::default(); + new(&mut engine, "a"); + let a = selected(&engine)[0]; + new(&mut engine, "b"); + let b = selected(&engine)[0]; + assert_eq!(engine.order.0, vec![a, b]); + + assert!(engine.execute(Action::Undo)); + assert_eq!(engine.order.0, vec![a]); + assert!(selected(&engine).is_empty()); + + assert!(engine.execute(Action::Redo)); + assert_eq!(engine.order.0, vec![a, b]); + + assert!(engine.execute(Action::Undo)); + assert!(engine.execute(Action::Undo)); + assert!(engine.order.0.is_empty()); + assert!(!engine.execute(Action::Undo)); + assert!(engine.stack.current().is_none()); + } + + #[test] + fn test_undo_updates_statistics() { + let mut engine = Engine::default(); + load(&mut engine, "data/simple.gpx"); + assert!(!engine.statistics().total_distance.is_empty()); + engine.execute(Action::Undo); + assert!(engine.statistics().total_distance.is_empty()); + engine.execute(Action::Redo); + engine.execute(Action::SelectAll); + assert_eq!(engine.statistics().total_distance.len(), 80); + } + + #[test] + fn test_selection_actions() { + let mut engine = Engine::default(); + new(&mut engine, "a"); + let a = selected(&engine)[0]; + new(&mut engine, "b"); + let b = selected(&engine)[0]; + assert_eq!(selected(&engine), vec![b]); + + assert!(engine.execute(Action::Select { file_ids: vec![a] })); + assert_eq!(selected(&engine), vec![a]); + + assert!(engine.execute(Action::AddSelect { file_ids: vec![b] })); + assert_eq!(selected(&engine).len(), 2); + + engine.execute(Action::Select { file_ids: vec![] }); + assert!(engine.execute(Action::SelectAll)); + assert_eq!(selected(&engine).len(), 2); + } + + #[test] + fn test_selection_does_not_create_undo_steps() { + let mut engine = Engine::default(); + new(&mut engine, "a"); + engine.execute(Action::SelectAll); + engine.execute(Action::Undo); + assert!(engine.stack.current().is_none()); } } diff --git a/gpx-rs/engine/src/engine/mod.rs b/gpx-rs/engine/src/engine/mod.rs index 9c5c7eedd..dacbd43ba 100644 --- a/gpx-rs/engine/src/engine/mod.rs +++ b/gpx-rs/engine/src/engine/mod.rs @@ -1,8 +1,10 @@ +mod action; mod command; mod derived; mod engine; mod state; +pub use action::*; pub use command::*; pub use derived::*; pub use engine::*; diff --git a/gpx-rs/engine/src/engine/state/selection.rs b/gpx-rs/engine/src/engine/state/selection.rs index 3cf7dee79..a2dc03118 100644 --- a/gpx-rs/engine/src/engine/state/selection.rs +++ b/gpx-rs/engine/src/engine/state/selection.rs @@ -1,6 +1,6 @@ use std::collections::HashSet; -use crate::{FileId, TrackId, TrackSegmentId, WaypointId}; +use crate::{FileId, StackEntry, TrackId, TrackSegmentId, WaypointId}; #[derive(Debug, Default)] pub enum Selection { @@ -26,3 +26,64 @@ pub enum Selection { wpt_ids: HashSet, }, } + +impl Selection { + /// Drops the files that do not exist anymore (e.g. after an undo). + pub fn retain_existing(&mut self, files: &StackEntry) { + match self { + Selection::Empty => {} + Selection::File { file_ids } => { + file_ids.retain(|id| files.contains_key(id)); + if file_ids.is_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; + } + } + } + } +} + +#[cfg(test)] +mod tests { + use std::rc::Rc; + + use crate::File; + + use super::*; + + #[test] + fn test_retain_existing() { + let file = File::default(); + let (kept, gone) = (file.id, FileId::default()); + let mut files = StackEntry::default(); + files.insert(kept, Rc::new(file)); + + let mut selection = Selection::File { + file_ids: [kept, gone].into(), + }; + selection.retain_existing(&files); + assert!( + matches!(&selection, Selection::File { file_ids } if file_ids.len() == 1 && file_ids.contains(&kept)) + ); + + let mut selection = Selection::File { + file_ids: [gone].into(), + }; + selection.retain_existing(&files); + assert!(matches!(selection, Selection::Empty)); + + let mut selection = Selection::Waypoints { file_id: kept }; + selection.retain_existing(&files); + assert!(matches!(selection, Selection::Waypoints { .. })); + let mut selection = Selection::Waypoints { file_id: gone }; + selection.retain_existing(&files); + assert!(matches!(selection, Selection::Empty)); + } +} diff --git a/gpx-rs/wasm/src/lib.rs b/gpx-rs/wasm/src/lib.rs index 06dceff5a..2c1911e10 100644 --- a/gpx-rs/wasm/src/lib.rs +++ b/gpx-rs/wasm/src/lib.rs @@ -11,7 +11,7 @@ use std::cell::RefCell; use wasm_bindgen::prelude::*; -use gpx_engine::{self as engine, Command, Engine, FileId, LngLat, LngLatBounds}; +use gpx_engine::{self as engine, Action, Command, Engine, FileId, LngLat, LngLatBounds}; use js_sys::{Float64Array, Int32Array}; #[wasm_bindgen] @@ -50,13 +50,17 @@ thread_local! { static ENGINE: RefCell> = RefCell::new(None); } -fn execute(command: Command) -> bool { +fn execute(action: Action) -> bool { ENGINE.with(|engine| match engine.borrow_mut().as_mut() { - Some(engine) => engine.execute(command), + Some(engine) => engine.execute(action), None => false, }) } +fn edit(command: Command) -> bool { + execute(Action::Edit(command)) +} + /// Decodes concatenated 16-byte UUIDs. fn file_ids(bytes: &[u8]) -> Option> { if bytes.len() % 16 != 0 { @@ -114,78 +118,78 @@ stats_getter!(slope_segment_distance, Float64Array); #[wasm_bindgen] pub fn new_file(name: &str) -> bool { - execute(Command::New { name }) + edit(Command::New(engine::New { name })) } #[wasm_bindgen] pub fn load_file(data: &[u8]) -> bool { - execute(Command::Load { data }) + edit(Command::Load(engine::Load { data })) } #[wasm_bindgen] pub fn duplicate() -> bool { - execute(Command::Duplicate) + edit(Command::Duplicate(engine::Duplicate)) } #[wasm_bindgen] pub fn delete() -> bool { - execute(Command::Delete) + edit(Command::Delete(engine::Delete)) } #[wasm_bindgen] pub fn delete_all() -> bool { - execute(Command::DeleteAll) + edit(Command::DeleteAll(engine::DeleteAll)) } // Edit commands #[wasm_bindgen] pub fn metadata(name: &str, desc: &str) -> bool { - execute(Command::Metadata { name, desc }) + edit(Command::Metadata(engine::Metadata { name, desc })) } #[wasm_bindgen] pub fn style(color: Option, opacity: Option, width: Option) -> bool { - execute(Command::Style { + edit(Command::Style(engine::Style { color: color.as_deref(), opacity, width, - }) + })) } #[wasm_bindgen] pub fn new_track() -> bool { - execute(Command::NewTrack) + edit(Command::NewTrack(engine::NewTrack)) } #[wasm_bindgen] pub fn new_track_segment() -> bool { - execute(Command::NewTrackSegment) + edit(Command::NewTrackSegment(engine::NewTrackSegment)) } // Tools #[wasm_bindgen] pub fn reverse() -> bool { - execute(Command::Reverse) + edit(Command::Reverse(engine::Reverse)) } #[wasm_bindgen] pub fn append(lng: &[f64], lat: &[f64], ele: &[f64]) -> bool { - same_len(lng, lat, ele) && execute(Command::Append { lng, lat, ele }) + same_len(lng, lat, ele) && edit(Command::Append(engine::Append { lng, lat, ele })) } #[wasm_bindgen] pub fn replace(start: u32, end: u32, lng: &[f64], lat: &[f64], ele: &[f64]) -> bool { start <= end && same_len(lng, lat, ele) - && execute(Command::Replace { + && edit(Command::Replace(engine::Replace { start, end, lng, lat, ele, - }) + })) } #[wasm_bindgen] @@ -198,7 +202,7 @@ pub fn new_waypoint( icon: &str, link: &str, ) -> bool { - execute(Command::NewWaypoint { + edit(Command::NewWaypoint(engine::NewWaypoint { lng, lat, ele, @@ -206,44 +210,48 @@ pub fn new_waypoint( desc, icon, link, - }) + })) } #[wasm_bindgen] pub fn move_waypoint(lng: f64, lat: f64, ele: f64) -> bool { - execute(Command::MoveWaypoint { lng, lat, ele }) + edit(Command::MoveWaypoint(engine::MoveWaypoint { + lng, + lat, + ele, + })) } #[wasm_bindgen] pub fn crop(start: u32, end: u32) -> bool { - start <= end && execute(Command::Crop { start, end }) + start <= end && edit(Command::Crop(engine::Crop { start, end })) } #[wasm_bindgen] pub fn split(at: u32) -> bool { - execute(Command::Split { at }) + edit(Command::Split(engine::Split { at })) } #[wasm_bindgen] pub fn time() -> bool { - execute(Command::Time) + edit(Command::Time(engine::Time)) } #[wasm_bindgen] pub fn merge(type_: MergeType) -> bool { - execute(Command::Merge { + edit(Command::Merge(engine::Merge { type_: type_.into(), - }) + })) } #[wasm_bindgen] pub fn extract() -> bool { - execute(Command::Extract) + edit(Command::Extract(engine::Extract)) } #[wasm_bindgen] pub fn elevation(ele: &[f64]) -> bool { - execute(Command::Elevation { ele }) + edit(Command::Elevation(engine::Elevation { ele })) } #[wasm_bindgen] @@ -256,7 +264,7 @@ pub fn clean( trkpt: bool, wpt: bool, ) -> bool { - execute(Command::Clean { + edit(Command::Clean(engine::Clean { bounds: LngLatBounds { sw: LngLat { lng: west, @@ -270,42 +278,42 @@ pub fn clean( type_: type_.into(), trkpt, wpt, - }) + })) } // Undo-redo #[wasm_bindgen] pub fn undo() -> bool { - execute(Command::Undo) + execute(Action::Undo) } #[wasm_bindgen] pub fn redo() -> bool { - execute(Command::Redo) + execute(Action::Redo) } // Selection -/// `file_ids`: concatenated 16-byte UUIDs. +/// `file_ids_bytes`: concatenated 16-byte UUIDs. #[wasm_bindgen] pub fn select(file_ids_bytes: &[u8]) -> bool { match file_ids(file_ids_bytes) { - Some(file_ids) => execute(Command::Select { file_ids }), + Some(file_ids) => execute(Action::Select { file_ids }), None => false, } } -/// `file_ids`: concatenated 16-byte UUIDs. +/// `file_ids_bytes`: concatenated 16-byte UUIDs. #[wasm_bindgen] pub fn add_select(file_ids_bytes: &[u8]) -> bool { match file_ids(file_ids_bytes) { - Some(file_ids) => execute(Command::AddSelect { file_ids }), + Some(file_ids) => execute(Action::AddSelect { file_ids }), None => false, } } #[wasm_bindgen] pub fn select_all() -> bool { - execute(Command::SelectAll) + execute(Action::SelectAll) }