This commit is contained in:
vcoppe
2026-10-03 16:40:17 +02:00
parent 64914f9aa0
commit bff84fa24c
38 changed files with 1413 additions and 518 deletions
+15
View File
@@ -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,
}
-114
View File
@@ -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);
}
}
}
}
+33
View File
@@ -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 {}
+56 -94
View File
@@ -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 /// 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. /// buffers, so a command is built without copying; the engine only copies what it keeps.
#[derive(Debug)]
pub enum Command<'a> { pub enum Command<'a> {
// File commands New(New<'a>),
New { Load(Load<'a>),
name: &'a str, Duplicate(Duplicate),
}, Delete(Delete),
Load { DeleteAll(DeleteAll),
data: &'a [u8], Metadata(Metadata<'a>),
}, Style(Style<'a>),
Duplicate, NewTrack(NewTrack),
Delete, NewTrackSegment(NewTrackSegment),
DeleteAll, Reverse(Reverse),
// Edit commands Append(Append<'a>),
Metadata { Replace(Replace<'a>),
name: &'a str, NewWaypoint(NewWaypoint<'a>),
desc: &'a str, MoveWaypoint(MoveWaypoint),
}, Crop(Crop),
Style { Split(Split),
color: Option<&'a str>, Time(Time),
opacity: Option<f64>, Merge(Merge),
width: Option<f64>, Extract(Extract),
}, Elevation(Elevation<'a>),
NewTrack, Clean(Clean),
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,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] impl Apply for Command<'_> {
pub enum MergeType { fn apply(self, state: &mut State) -> Result<(), CommandError> {
Connect, match self {
Group, Command::New(c) => c.apply(state),
} Command::Load(c) => c.apply(state),
Command::Duplicate(c) => c.apply(state),
#[derive(Debug, Clone, Copy, PartialEq, Eq)] Command::Delete(c) => c.apply(state),
pub enum CleanType { Command::DeleteAll(c) => c.apply(state),
Inside, Command::Metadata(c) => c.apply(state),
Outside, 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(),
}
}
}
+10 -2
View File
@@ -1,7 +1,15 @@
mod applier; mod apply;
mod command; mod command;
mod edit;
mod file;
#[cfg(test)]
mod fixture;
mod pattern; mod pattern;
mod tools;
pub use applier::*; pub use apply::*;
pub use command::*; pub use command::*;
pub use edit::*;
pub use file::*;
pub use pattern::*; pub use pattern::*;
pub use tools::*;
-179
View File
@@ -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"))
}
}
+216 -92
View File
@@ -1,8 +1,10 @@
// TODO license in every file // TODO license in every file
use std::collections::HashSet;
use crate::{ use crate::{
Command, FileOrder, Selection, Stack, State, StatisticsBuffer, StatisticsCache, create_file, Action, Apply, Command, FileId, FileOrder, Selection, Stack, State, StatisticsBuffer,
load_file, update_metadata, StatisticsCache,
}; };
#[derive(Debug, Default)] #[derive(Debug, Default)]
@@ -19,97 +21,116 @@ impl Engine {
&self.statistics_buffer &self.statistics_buffer
} }
// pub fn delete_file(&mut self, id: &[u8]) -> bool { /// Single entry point for every action coming from the frontend. Returns whether anything
// self.event_loop(|entry| { /// changed.
// if let Ok(id) = Uuid::from_slice(id) { pub fn execute(&mut self, action: Action) -> bool {
// let id = FileId(id); let changed = match action {
// entry.remove(&id).is_some() Action::Edit(command) => self.edit(command),
// } else { Action::Undo => self.stack.undo().is_some(),
// false Action::Redo => self.stack.redo().is_some(),
// } Action::Select { file_ids } => {
// }) self.selection = Selection::File {
// } file_ids: file_ids.into_iter().collect(),
};
/// Single entry point for every user action coming from the frontend. true
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"),
} }
}) { Action::AddSelect { file_ids } => {
self.statistics_cache.update(self.stack.current()); match &mut self.selection {
self.statistics_buffer.update( Selection::File { file_ids: ids } => ids.extend(file_ids),
&self selection => {
.statistics_cache *selection = Selection::File {
.get(self.stack.current(), &self.selection), file_ids: file_ids.into_iter().collect(),
); }
true }
} else { }
false 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)] #[cfg(test)]
mod tests { mod tests {
use std::{fs::File, io::Read}; use crate::{Load, Metadata, New, NewTrack, Style};
use super::*; use super::*;
#[test] fn edit(engine: &mut Engine, command: Command) -> bool {
fn test_load_file() { engine.execute(Action::Edit(command))
let mut engine = Engine::default(); }
let mut f = File::open("data/simple.gpx").unwrap(); fn new(engine: &mut Engine, name: &str) -> bool {
let mut data = String::new(); edit(engine, Command::New(New { name }))
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 load(engine: &mut Engine, path: &str) -> bool { fn load(engine: &mut Engine, path: &str) -> bool {
let data = std::fs::read(path).unwrap(); 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] #[test]
fn test_new_file_has_no_statistics() { fn test_new_file_has_no_statistics() {
let mut engine = Engine::default(); 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.stack.current().unwrap().len(), 1);
assert_eq!(engine.order.0.len(), 1); assert_eq!(engine.order.0.len(), 1);
assert!(engine.statistics().total_distance.is_empty()); assert!(engine.statistics().total_distance.is_empty());
@@ -123,7 +144,7 @@ mod tests {
assert!(n > 0); assert!(n > 0);
// a newly created (empty) file becomes the selection // 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!(engine.statistics().total_distance.is_empty());
assert_eq!(engine.stack.current().unwrap().len(), 2); assert_eq!(engine.stack.current().unwrap().len(), 2);
@@ -135,39 +156,142 @@ mod tests {
#[test] #[test]
fn test_invalid_load_is_rejected() { fn test_invalid_load_is_rejected() {
let mut engine = Engine::default(); let mut engine = Engine::default();
assert!(!engine.execute(Command::Load { assert!(!edit(
data: b"<gpx><trk></gpx>" &mut engine,
})); Command::Load(Load {
data: b"<gpx><trk></gpx>"
})
));
assert!(engine.stack.current().is_none()); assert!(engine.stack.current().is_none());
assert!(!engine.stack.can_undo()); assert!(!engine.stack.can_undo());
assert!(engine.order.0.is_empty()); 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] #[test]
fn test_metadata_renames_selected_file() { fn test_metadata_renames_selected_file() {
let mut engine = Engine::default(); let mut engine = Engine::default();
engine.execute(Command::New { name: "before" }); new(&mut engine, "before");
assert!(engine.execute(Command::Metadata { assert!(edit(
name: "after", &mut engine,
desc: "about", Command::Metadata(Metadata {
})); name: "after",
let state = engine.stack.current().unwrap(); desc: "about",
let file = state.values().next().unwrap(); })
));
let file = engine.stack.current().unwrap().values().next().unwrap();
assert_eq!(file.info.name, "after"); assert_eq!(file.info.name, "after");
assert_eq!(file.info.desc.as_deref(), Some("about")); assert_eq!(file.info.desc.as_deref(), Some("about"));
assert!(engine.stack.can_undo());
} }
#[test] #[test]
fn test_metadata_without_selection_does_not_modify_files() { fn test_metadata_without_selection_does_not_modify_files() {
let mut engine = Engine::default(); let mut engine = Engine::default();
engine.execute(Command::New { name: "file" }); new(&mut engine, "file");
engine.selection = Selection::Empty; engine.selection = Selection::Empty;
engine.execute(Command::Metadata { edit(
name: "renamed", &mut engine,
desc: "", Command::Metadata(Metadata {
}); name: "renamed",
let state = engine.stack.current().unwrap(); desc: "",
assert_eq!(state.values().next().unwrap().info.name, "file"); }),
);
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());
} }
} }
+2
View File
@@ -1,8 +1,10 @@
mod action;
mod command; mod command;
mod derived; mod derived;
mod engine; mod engine;
mod state; mod state;
pub use action::*;
pub use command::*; pub use command::*;
pub use derived::*; pub use derived::*;
pub use engine::*; pub use engine::*;
+62 -1
View File
@@ -1,6 +1,6 @@
use std::collections::HashSet; use std::collections::HashSet;
use crate::{FileId, TrackId, TrackSegmentId, WaypointId}; use crate::{FileId, StackEntry, TrackId, TrackSegmentId, WaypointId};
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub enum Selection { pub enum Selection {
@@ -26,3 +26,64 @@ pub enum Selection {
wpt_ids: HashSet<WaypointId>, 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
View File
@@ -11,7 +11,7 @@ use std::cell::RefCell;
use wasm_bindgen::prelude::*; 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}; use js_sys::{Float64Array, Int32Array};
#[wasm_bindgen] #[wasm_bindgen]
@@ -50,13 +50,17 @@ thread_local! {
static ENGINE: RefCell<Option<Engine>> = RefCell::new(None); 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() { ENGINE.with(|engine| match engine.borrow_mut().as_mut() {
Some(engine) => engine.execute(command), Some(engine) => engine.execute(action),
None => false, None => false,
}) })
} }
fn edit(command: Command) -> bool {
execute(Action::Edit(command))
}
/// Decodes concatenated 16-byte UUIDs. /// Decodes concatenated 16-byte UUIDs.
fn file_ids(bytes: &[u8]) -> Option<Vec<FileId>> { fn file_ids(bytes: &[u8]) -> Option<Vec<FileId>> {
if bytes.len() % 16 != 0 { if bytes.len() % 16 != 0 {
@@ -114,78 +118,78 @@ stats_getter!(slope_segment_distance, Float64Array);
#[wasm_bindgen] #[wasm_bindgen]
pub fn new_file(name: &str) -> bool { pub fn new_file(name: &str) -> bool {
execute(Command::New { name }) edit(Command::New(engine::New { name }))
} }
#[wasm_bindgen] #[wasm_bindgen]
pub fn load_file(data: &[u8]) -> bool { pub fn load_file(data: &[u8]) -> bool {
execute(Command::Load { data }) edit(Command::Load(engine::Load { data }))
} }
#[wasm_bindgen] #[wasm_bindgen]
pub fn duplicate() -> bool { pub fn duplicate() -> bool {
execute(Command::Duplicate) edit(Command::Duplicate(engine::Duplicate))
} }
#[wasm_bindgen] #[wasm_bindgen]
pub fn delete() -> bool { pub fn delete() -> bool {
execute(Command::Delete) edit(Command::Delete(engine::Delete))
} }
#[wasm_bindgen] #[wasm_bindgen]
pub fn delete_all() -> bool { pub fn delete_all() -> bool {
execute(Command::DeleteAll) edit(Command::DeleteAll(engine::DeleteAll))
} }
// Edit commands // Edit commands
#[wasm_bindgen] #[wasm_bindgen]
pub fn metadata(name: &str, desc: &str) -> bool { pub fn metadata(name: &str, desc: &str) -> bool {
execute(Command::Metadata { name, desc }) edit(Command::Metadata(engine::Metadata { name, desc }))
} }
#[wasm_bindgen] #[wasm_bindgen]
pub fn style(color: Option<String>, opacity: Option<f64>, width: Option<f64>) -> bool { 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(), color: color.as_deref(),
opacity, opacity,
width, width,
}) }))
} }
#[wasm_bindgen] #[wasm_bindgen]
pub fn new_track() -> bool { pub fn new_track() -> bool {
execute(Command::NewTrack) edit(Command::NewTrack(engine::NewTrack))
} }
#[wasm_bindgen] #[wasm_bindgen]
pub fn new_track_segment() -> bool { pub fn new_track_segment() -> bool {
execute(Command::NewTrackSegment) edit(Command::NewTrackSegment(engine::NewTrackSegment))
} }
// Tools // Tools
#[wasm_bindgen] #[wasm_bindgen]
pub fn reverse() -> bool { pub fn reverse() -> bool {
execute(Command::Reverse) edit(Command::Reverse(engine::Reverse))
} }
#[wasm_bindgen] #[wasm_bindgen]
pub fn append(lng: &[f64], lat: &[f64], ele: &[f64]) -> bool { 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] #[wasm_bindgen]
pub fn replace(start: u32, end: u32, lng: &[f64], lat: &[f64], ele: &[f64]) -> bool { pub fn replace(start: u32, end: u32, lng: &[f64], lat: &[f64], ele: &[f64]) -> bool {
start <= end start <= end
&& same_len(lng, lat, ele) && same_len(lng, lat, ele)
&& execute(Command::Replace { && edit(Command::Replace(engine::Replace {
start, start,
end, end,
lng, lng,
lat, lat,
ele, ele,
}) }))
} }
#[wasm_bindgen] #[wasm_bindgen]
@@ -198,7 +202,7 @@ pub fn new_waypoint(
icon: &str, icon: &str,
link: &str, link: &str,
) -> bool { ) -> bool {
execute(Command::NewWaypoint { edit(Command::NewWaypoint(engine::NewWaypoint {
lng, lng,
lat, lat,
ele, ele,
@@ -206,44 +210,48 @@ pub fn new_waypoint(
desc, desc,
icon, icon,
link, link,
}) }))
} }
#[wasm_bindgen] #[wasm_bindgen]
pub fn move_waypoint(lng: f64, lat: f64, ele: f64) -> bool { 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] #[wasm_bindgen]
pub fn crop(start: u32, end: u32) -> bool { 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] #[wasm_bindgen]
pub fn split(at: u32) -> bool { pub fn split(at: u32) -> bool {
execute(Command::Split { at }) edit(Command::Split(engine::Split { at }))
} }
#[wasm_bindgen] #[wasm_bindgen]
pub fn time() -> bool { pub fn time() -> bool {
execute(Command::Time) edit(Command::Time(engine::Time))
} }
#[wasm_bindgen] #[wasm_bindgen]
pub fn merge(type_: MergeType) -> bool { pub fn merge(type_: MergeType) -> bool {
execute(Command::Merge { edit(Command::Merge(engine::Merge {
type_: type_.into(), type_: type_.into(),
}) }))
} }
#[wasm_bindgen] #[wasm_bindgen]
pub fn extract() -> bool { pub fn extract() -> bool {
execute(Command::Extract) edit(Command::Extract(engine::Extract))
} }
#[wasm_bindgen] #[wasm_bindgen]
pub fn elevation(ele: &[f64]) -> bool { pub fn elevation(ele: &[f64]) -> bool {
execute(Command::Elevation { ele }) edit(Command::Elevation(engine::Elevation { ele }))
} }
#[wasm_bindgen] #[wasm_bindgen]
@@ -256,7 +264,7 @@ pub fn clean(
trkpt: bool, trkpt: bool,
wpt: bool, wpt: bool,
) -> bool { ) -> bool {
execute(Command::Clean { edit(Command::Clean(engine::Clean {
bounds: LngLatBounds { bounds: LngLatBounds {
sw: LngLat { sw: LngLat {
lng: west, lng: west,
@@ -270,42 +278,42 @@ pub fn clean(
type_: type_.into(), type_: type_.into(),
trkpt, trkpt,
wpt, wpt,
}) }))
} }
// Undo-redo // Undo-redo
#[wasm_bindgen] #[wasm_bindgen]
pub fn undo() -> bool { pub fn undo() -> bool {
execute(Command::Undo) execute(Action::Undo)
} }
#[wasm_bindgen] #[wasm_bindgen]
pub fn redo() -> bool { pub fn redo() -> bool {
execute(Command::Redo) execute(Action::Redo)
} }
// Selection // Selection
/// `file_ids`: concatenated 16-byte UUIDs. /// `file_ids_bytes`: concatenated 16-byte UUIDs.
#[wasm_bindgen] #[wasm_bindgen]
pub fn select(file_ids_bytes: &[u8]) -> bool { pub fn select(file_ids_bytes: &[u8]) -> bool {
match file_ids(file_ids_bytes) { match file_ids(file_ids_bytes) {
Some(file_ids) => execute(Command::Select { file_ids }), Some(file_ids) => execute(Action::Select { file_ids }),
None => false, None => false,
} }
} }
/// `file_ids`: concatenated 16-byte UUIDs. /// `file_ids_bytes`: concatenated 16-byte UUIDs.
#[wasm_bindgen] #[wasm_bindgen]
pub fn add_select(file_ids_bytes: &[u8]) -> bool { pub fn add_select(file_ids_bytes: &[u8]) -> bool {
match file_ids(file_ids_bytes) { match file_ids(file_ids_bytes) {
Some(file_ids) => execute(Command::AddSelect { file_ids }), Some(file_ids) => execute(Action::AddSelect { file_ids }),
None => false, None => false,
} }
} }
#[wasm_bindgen] #[wasm_bindgen]
pub fn select_all() -> bool { pub fn select_all() -> bool {
execute(Command::SelectAll) execute(Action::SelectAll)
} }