mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-04 12:58:23 +00:00
progress
This commit is contained in:
@@ -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<FileId> },
|
||||
AddSelect { file_ids: Vec<FileId> },
|
||||
SelectAll,
|
||||
}
|
||||
@@ -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<FileId> {
|
||||
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"<gpx><trk></gpx>").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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<f64>,
|
||||
width: Option<f64>,
|
||||
},
|
||||
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<FileId>,
|
||||
},
|
||||
AddSelect {
|
||||
file_ids: Vec<FileId>,
|
||||
},
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<f64>,
|
||||
pub width: Option<f64>,
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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"<gpx><trk></gpx>",
|
||||
}
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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<FileId> {
|
||||
match &self.selection {
|
||||
Selection::File { file_ids } => file_ids.clone(),
|
||||
_ => HashSet::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
use std::{
|
||||
collections::{HashSet, hash_map::Entry},
|
||||
rc::Rc,
|
||||
};
|
||||
|
||||
use crate::{File, FileId, Selection, State};
|
||||
|
||||
pub fn produce<F>(state: &mut State, f: F)
|
||||
where
|
||||
F: FnOnce(&State) -> Vec<File>,
|
||||
{
|
||||
*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<FileId> = Default::default();
|
||||
file_ids.insert(file_id);
|
||||
*state.selection = Selection::File { file_ids };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_each_selected_file<F>(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<FileId> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
mod produce;
|
||||
mod update_selected;
|
||||
|
||||
pub use produce::*;
|
||||
pub use update_selected::*;
|
||||
@@ -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<F>(state: &mut State, f: F)
|
||||
where
|
||||
F: FnOnce(&State) -> Vec<File>,
|
||||
{
|
||||
*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![]
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<E: Editor + ?Sized>(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<T>(items: &mut [T], f: impl FnMut(&mut T) -> Edit) -> Edit {
|
||||
edit_where(items, |_| true, f)
|
||||
}
|
||||
|
||||
fn edit_where<T>(
|
||||
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<E: Editor>(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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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<FileId> = 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<FileId> {
|
||||
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"<gpx><trk></gpx>"
|
||||
}));
|
||||
assert!(!edit(
|
||||
&mut engine,
|
||||
Command::Load(Load {
|
||||
data: b"<gpx><trk></gpx>"
|
||||
})
|
||||
));
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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<WaypointId>,
|
||||
},
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
+44
-36
@@ -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<Option<Engine>> = 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<Vec<FileId>> {
|
||||
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<String>, opacity: Option<f64>, width: Option<f64>) -> 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user