mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-17 08:42:28 +00:00
progress
This commit is contained in:
1 parent
ce8398f749
commit
41b9116c6a
60 files changed
+7525
-340
No files matched your search
@@ -0,0 +1,246 @@
|
||||
//! Editing the metadata, the style, the structure and the waypoints of the files, and the tools:
|
||||
//! reversing, cropping, reducing, timestamps, merging, cleaning...
|
||||
|
||||
use gpx_engine::{self as engine, Command, LngLat, LngLatBounds};
|
||||
use tauri::State;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::*;
|
||||
use crate::state::{act, edit, AppState};
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn metadata(state: State<AppState>, name: String, desc: String) -> Outcome {
|
||||
edit!(
|
||||
state,
|
||||
Command::Metadata(engine::Metadata {
|
||||
name: &name,
|
||||
desc: &desc,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn style(
|
||||
state: State<AppState>,
|
||||
color: Option<String>,
|
||||
opacity: Option<f64>,
|
||||
width: Option<f64>,
|
||||
) -> Outcome {
|
||||
edit!(
|
||||
state,
|
||||
Command::Style(engine::Style {
|
||||
color: color.as_deref(),
|
||||
opacity,
|
||||
width,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn new_track(state: State<AppState>) -> Outcome {
|
||||
edit!(state, Command::NewTrack(engine::NewTrack))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn new_track_segment(state: State<AppState>) -> Outcome {
|
||||
edit!(state, Command::NewTrackSegment(engine::NewTrackSegment))
|
||||
}
|
||||
|
||||
// Waypoints
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new_waypoint(
|
||||
state: State<AppState>,
|
||||
lng: f64,
|
||||
lat: f64,
|
||||
ele: f64,
|
||||
name: String,
|
||||
desc: String,
|
||||
icon: String,
|
||||
link: String,
|
||||
) -> Outcome {
|
||||
edit!(
|
||||
state,
|
||||
Command::NewWaypoint(engine::NewWaypoint {
|
||||
lng,
|
||||
lat,
|
||||
ele,
|
||||
name: &name,
|
||||
desc: &desc,
|
||||
icon: &icon,
|
||||
link: &link,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn move_waypoint(
|
||||
state: State<AppState>,
|
||||
file_id: Uuid,
|
||||
waypoint_id: Uuid,
|
||||
lng: f64,
|
||||
lat: f64,
|
||||
ele: f64,
|
||||
) -> Outcome {
|
||||
edit!(
|
||||
state,
|
||||
Command::MoveWaypoint(engine::MoveWaypoint {
|
||||
file_id: engine::FileId(file_id),
|
||||
waypoint_id: engine::WaypointId(waypoint_id),
|
||||
lng,
|
||||
lat,
|
||||
ele,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn delete_waypoint(state: State<AppState>, file_id: Uuid, waypoint_id: Uuid) -> Outcome {
|
||||
edit!(
|
||||
state,
|
||||
Command::DeleteWaypoint(engine::DeleteWaypoint {
|
||||
file_id: engine::FileId(file_id),
|
||||
waypoint_id: engine::WaypointId(waypoint_id),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn update_waypoint(
|
||||
state: State<AppState>,
|
||||
file_id: Uuid,
|
||||
waypoint_id: Uuid,
|
||||
lng: f64,
|
||||
lat: f64,
|
||||
ele: f64,
|
||||
name: String,
|
||||
desc: String,
|
||||
icon: String,
|
||||
link: String,
|
||||
) -> Outcome {
|
||||
edit!(
|
||||
state,
|
||||
Command::EditWaypoint(engine::EditWaypoint {
|
||||
file_id: engine::FileId(file_id),
|
||||
waypoint_id: engine::WaypointId(waypoint_id),
|
||||
lng,
|
||||
lat,
|
||||
ele,
|
||||
name: &name,
|
||||
desc: &desc,
|
||||
icon: &icon,
|
||||
link: &link,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Tools
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn reverse(state: State<AppState>) -> Outcome {
|
||||
edit!(state, Command::Reverse(engine::Reverse))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn round_trip(state: State<AppState>) -> Outcome {
|
||||
edit!(state, Command::RoundTrip(engine::RoundTrip))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn crop(state: State<AppState>, start: u32, end: u32) -> Outcome {
|
||||
if start > end {
|
||||
return Outcome::failed(format!("the range {start}..{end} is reversed"));
|
||||
}
|
||||
edit!(state, Command::Crop(engine::Crop { start, end }))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn reduce(state: State<AppState>, tolerance: f64) -> Outcome {
|
||||
edit!(state, Command::Reduce(engine::Reduce { tolerance }))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn change_timestamps(
|
||||
state: State<AppState>,
|
||||
start_time: f64,
|
||||
speed: f64,
|
||||
ratio: f64,
|
||||
) -> Outcome {
|
||||
edit!(
|
||||
state,
|
||||
Command::Time(engine::Time {
|
||||
start_time: start_time as i64,
|
||||
kind: engine::TimeKind::Change { speed, ratio },
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn create_artificial_timestamps(
|
||||
state: State<AppState>,
|
||||
start_time: f64,
|
||||
total_time: f64,
|
||||
) -> Outcome {
|
||||
edit!(
|
||||
state,
|
||||
Command::Time(engine::Time {
|
||||
start_time: start_time as i64,
|
||||
kind: engine::TimeKind::Artificial { total_time },
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn merge(state: State<AppState>, merge_type: MergeType, remove_gaps: bool) -> Outcome {
|
||||
edit!(
|
||||
state,
|
||||
Command::Merge(engine::Merge {
|
||||
type_: merge_type.into(),
|
||||
remove_gaps,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn extract(state: State<AppState>) -> Outcome {
|
||||
edit!(state, Command::Extract(engine::Extract))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn elevation(state: State<AppState>, ele: Vec<f64>) -> Outcome {
|
||||
edit!(state, Command::Elevation(engine::Elevation { ele: &ele }))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn clean(
|
||||
state: State<AppState>,
|
||||
west: f64,
|
||||
south: f64,
|
||||
east: f64,
|
||||
north: f64,
|
||||
clean_type: CleanType,
|
||||
trkpt: bool,
|
||||
wpt: bool,
|
||||
) -> Outcome {
|
||||
edit!(
|
||||
state,
|
||||
Command::Clean(engine::Clean {
|
||||
bounds: LngLatBounds {
|
||||
sw: LngLat {
|
||||
lng: west,
|
||||
lat: south,
|
||||
},
|
||||
ne: LngLat {
|
||||
lng: east,
|
||||
lat: north,
|
||||
},
|
||||
},
|
||||
type_: clean_type.into(),
|
||||
trkpt,
|
||||
wpt,
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Creating, loading, deleting, moving, copying and pasting files and their elements, and undoing.
|
||||
|
||||
use gpx_engine::{self as engine, Action, Command};
|
||||
use tauri::ipc::{InvokeBody, Request};
|
||||
use tauri::State;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::*;
|
||||
use crate::state::{act, edit, AppState};
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn new_file(
|
||||
state: State<AppState>,
|
||||
name: String,
|
||||
lng: Option<f64>,
|
||||
lat: Option<f64>,
|
||||
ele: Option<f64>,
|
||||
) -> Outcome {
|
||||
let trackpoint = lng.zip(lat).map(|(lng, lat)| engine::NewTrackpoint {
|
||||
lng,
|
||||
lat,
|
||||
ele: ele.unwrap_or_default(),
|
||||
});
|
||||
edit!(
|
||||
state,
|
||||
Command::New(engine::New {
|
||||
name: &name,
|
||||
trackpoint,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/// The files are in the body, one after the other, the headers `lengths` and `names` (JSON) telling
|
||||
/// the number of bytes and the name of each one.
|
||||
#[tauri::command(async)]
|
||||
pub fn load_files(state: State<AppState>, request: Request<'_>) -> Outcome {
|
||||
let InvokeBody::Raw(data) = request.body() else {
|
||||
return Outcome::failed("the files are missing");
|
||||
};
|
||||
let header = |key: &str| {
|
||||
request
|
||||
.headers()
|
||||
.get(key)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(urlencoding_decode)
|
||||
};
|
||||
let (Some(lengths), Some(names)) = (
|
||||
header("lengths").and_then(|json| serde_json::from_str::<Vec<usize>>(&json).ok()),
|
||||
header("names").and_then(|json| serde_json::from_str::<Vec<String>>(&json).ok()),
|
||||
) else {
|
||||
return Outcome::failed("the lengths or the names of the files are not valid");
|
||||
};
|
||||
if lengths.len() != names.len() {
|
||||
return Outcome::failed(format!(
|
||||
"{} lengths for {} names",
|
||||
lengths.len(),
|
||||
names.len()
|
||||
));
|
||||
}
|
||||
let (data, mut offset) = (data.clone(), 0);
|
||||
let mut ranges = Vec::with_capacity(names.len());
|
||||
for (length, name) in lengths.iter().zip(&names) {
|
||||
let end = offset + length;
|
||||
if end > data.len() {
|
||||
return Outcome::failed(format!("the data of {name} is shorter than its length"));
|
||||
}
|
||||
ranges.push(offset..end);
|
||||
offset = end;
|
||||
}
|
||||
edit!(state, {
|
||||
let files = ranges
|
||||
.into_iter()
|
||||
.zip(&names)
|
||||
.map(|(range, name)| engine::Load {
|
||||
data: &data[range],
|
||||
name,
|
||||
})
|
||||
.collect();
|
||||
Command::LoadFiles(engine::LoadFiles { files })
|
||||
})
|
||||
}
|
||||
|
||||
/// Headers are ASCII: the frontend percent-encodes the JSON.
|
||||
fn urlencoding_decode(value: &str) -> Option<String> {
|
||||
let bytes = value.as_bytes();
|
||||
let mut decoded = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' {
|
||||
let hex = value.get(i + 1..i + 3)?;
|
||||
decoded.push(u8::from_str_radix(hex, 16).ok()?);
|
||||
i += 3;
|
||||
} else {
|
||||
decoded.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
String::from_utf8(decoded).ok()
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn duplicate(state: State<AppState>) -> Outcome {
|
||||
edit!(state, Command::Duplicate(engine::Duplicate))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn delete(state: State<AppState>, whole_files: bool) -> Outcome {
|
||||
edit!(state, Command::Delete(engine::Delete { whole_files }))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn delete_all(state: State<AppState>) -> Outcome {
|
||||
edit!(state, Command::DeleteAll(engine::DeleteAll))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn reorder(state: State<AppState>, file_ids: Vec<Uuid>, index: usize) -> Outcome {
|
||||
act!(
|
||||
state,
|
||||
Action::Reorder {
|
||||
file_ids: file_ids.into_iter().map(engine::FileId).collect(),
|
||||
index,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn copy(state: State<AppState>) -> Outcome {
|
||||
act!(state, Action::Copy)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn cut(state: State<AppState>) -> Outcome {
|
||||
act!(state, Action::Cut)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn paste(state: State<AppState>) -> Outcome {
|
||||
edit!(state, Command::Paste(engine::Paste))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn move_elements(state: State<AppState>, what: Selection, to: MoveTarget) -> Outcome {
|
||||
edit!(
|
||||
state,
|
||||
Command::Move(engine::Move {
|
||||
what: what.into(),
|
||||
to: to.into(),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn undo(state: State<AppState>) -> Outcome {
|
||||
act!(state, Action::Undo)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn redo(state: State<AppState>) -> Outcome {
|
||||
act!(state, Action::Redo)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//! The functions of the frontend's interface to the engine, as Tauri commands. They are the
|
||||
//! counterparts of the functions that `gpx-rs/wasm` exports, and cross the same data: see
|
||||
//! `website/src/lib/engine/backend`.
|
||||
//!
|
||||
//! The commands that edit the files or change the selection return an `Outcome`.
|
||||
//! They are `async` (so they run off the main thread), the frontend sends them one after the other.
|
||||
|
||||
pub mod edit;
|
||||
pub mod files;
|
||||
pub mod queries;
|
||||
pub mod routing;
|
||||
pub mod selection;
|
||||
pub mod statistics;
|
||||
pub mod storage;
|
||||
@@ -0,0 +1,136 @@
|
||||
//! The files, their structure and statistics, the selection, the clipboard, and the coordinates of
|
||||
//! the trackpoints and waypoints (flat `[lng, lat, ...]` little-endian `f64` arrays).
|
||||
|
||||
use gpx_engine::{self as engine, FileId};
|
||||
use tauri::State;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::*;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn file_order(state: State<AppState>) -> Vec<String> {
|
||||
state.read(|e| ids(e.order()))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn file_structure(state: State<AppState>, file_id: Uuid) -> Option<FileStructure> {
|
||||
state.read(move |e| e.file_structure(&FileId(file_id)).map(FileStructure::new))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn file_statistics(state: State<AppState>, file_id: Uuid) -> Option<GlobalStatistics> {
|
||||
state.read(move |e| {
|
||||
e.file_statistics(&FileId(file_id))
|
||||
.map(|s| GlobalStatistics::new(&s))
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn selection(state: State<AppState>) -> Selection {
|
||||
state.read(|e| e.selection().into())
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn clipboard(state: State<AppState>) -> Option<Clipboard> {
|
||||
state.read(|e| e.clipboard().map(Clipboard::new))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn can_undo(state: State<AppState>) -> bool {
|
||||
state.read(|e| e.can_undo())
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn can_redo(state: State<AppState>) -> bool {
|
||||
state.read(|e| e.can_redo())
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn can_paste(state: State<AppState>) -> bool {
|
||||
state.read(|e| e.can_paste())
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn waypoint(
|
||||
state: State<AppState>,
|
||||
file_id: Uuid,
|
||||
waypoint_id: Uuid,
|
||||
) -> Option<WaypointDetails> {
|
||||
state.read(move |e| {
|
||||
e.waypoint(&FileId(file_id), &engine::WaypointId(waypoint_id))
|
||||
.map(WaypointDetails::new)
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn trackpoint(
|
||||
state: State<AppState>,
|
||||
file_id: Uuid,
|
||||
segment_id: Uuid,
|
||||
index: usize,
|
||||
) -> Option<TrackpointDetails> {
|
||||
state.read(move |e| {
|
||||
e.trackpoint(&FileId(file_id), &engine::TrackSegmentId(segment_id), index)
|
||||
.map(TrackpointDetails::new)
|
||||
})
|
||||
}
|
||||
|
||||
/// A file as GPX (UTF-8 bytes), the first byte telling whether the file exists.
|
||||
#[tauri::command(async)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn export_file(
|
||||
state: State<AppState>,
|
||||
file_id: Uuid,
|
||||
time: bool,
|
||||
hr: bool,
|
||||
cad: bool,
|
||||
atemp: bool,
|
||||
power: bool,
|
||||
osm: bool,
|
||||
as_route: bool,
|
||||
) -> Bytes {
|
||||
let options = engine::ExportOptions {
|
||||
time,
|
||||
hr,
|
||||
cad,
|
||||
atemp,
|
||||
power,
|
||||
osm,
|
||||
as_route,
|
||||
};
|
||||
optional(state.read(move |e| e.export(&FileId(file_id), options)))
|
||||
}
|
||||
|
||||
/// The data that some files have: a bit for the time (1), heart rate (2), cadence (4), temperature
|
||||
/// (8), power (16) and the OpenStreetMap data (32).
|
||||
#[tauri::command(async)]
|
||||
pub fn exportable_data(state: State<AppState>, file_ids: Vec<Uuid>) -> u8 {
|
||||
state.read(move |e| {
|
||||
let ids: Vec<FileId> = file_ids.into_iter().map(FileId).collect();
|
||||
let data = e.exportable_data(&ids);
|
||||
[
|
||||
data.time, data.hr, data.cad, data.atemp, data.power, data.osm,
|
||||
]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.fold(0, |bits, (i, on)| bits | (u8::from(*on) << i))
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn reduction_distances(state: State<AppState>) -> Bytes {
|
||||
Bytes::new(state.read(|e| f64s(&e.reduction_distances())))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn segment_coordinates(state: State<AppState>, segment_id: Uuid) -> Bytes {
|
||||
Bytes::new(
|
||||
state.read(move |e| f64s(e.segment_coordinates(&engine::TrackSegmentId(segment_id)))),
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn waypoint_coordinates(state: State<AppState>, file_id: Uuid) -> Bytes {
|
||||
Bytes::new(state.read(move |e| f64s(e.waypoint_coordinates(&FileId(file_id)))))
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! The commands that refer to trackpoints by their index in the routing buffers, which only mean
|
||||
//! something for the `revision` they were read with: the commands do nothing if the selection is
|
||||
//! not the one of that revision anymore.
|
||||
|
||||
use gpx_engine::{self as engine, Command};
|
||||
use tauri::State;
|
||||
|
||||
use crate::dto::*;
|
||||
use crate::state::{edit_at, AppState};
|
||||
|
||||
#[tauri::command(async)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn route(
|
||||
state: State<AppState>,
|
||||
revision: u32,
|
||||
start: u32,
|
||||
end: u32,
|
||||
lng: Vec<f64>,
|
||||
lat: Vec<f64>,
|
||||
ele: Vec<f64>,
|
||||
attributes: RouteAttributes,
|
||||
anchors: Vec<u32>,
|
||||
) -> Outcome {
|
||||
if lng.len() != lat.len() || lat.len() != ele.len() {
|
||||
return Outcome::failed("lng, lat and ele differ in length");
|
||||
}
|
||||
edit_at!(
|
||||
state,
|
||||
revision,
|
||||
Command::Route(engine::Route {
|
||||
start,
|
||||
end,
|
||||
lng: &lng,
|
||||
lat: &lat,
|
||||
ele: &ele,
|
||||
surface: attributes.surface.view(),
|
||||
highway: attributes.highway.view(),
|
||||
sac_scale: attributes.sac_scale.view(),
|
||||
mtb_scale: attributes.mtb_scale.view(),
|
||||
anchors: &anchors,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn insert_anchor(state: State<AppState>, revision: u32, lng: f64, lat: f64) -> Outcome {
|
||||
edit_at!(
|
||||
state,
|
||||
revision,
|
||||
Command::InsertAnchor(engine::InsertAnchor { lng, lat })
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn change_loop_start(state: State<AppState>, revision: u32, index: u32) -> Outcome {
|
||||
edit_at!(
|
||||
state,
|
||||
revision,
|
||||
Command::ChangeLoopStart(engine::ChangeLoopStart { index })
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn split(state: State<AppState>, revision: u32, at: u32, split_type: SplitType) -> Outcome {
|
||||
edit_at!(
|
||||
state,
|
||||
revision,
|
||||
Command::Split(engine::Split {
|
||||
at,
|
||||
split_type: split_type.into(),
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Changing what is selected.
|
||||
|
||||
use gpx_engine::{self as engine, Action};
|
||||
use tauri::State;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::*;
|
||||
use crate::state::{act, AppState};
|
||||
|
||||
fn select_elements(
|
||||
state: &AppState,
|
||||
selection: Option<engine::Selection>,
|
||||
mode: SelectMode,
|
||||
) -> Outcome {
|
||||
match selection {
|
||||
Some(selection) => act!(
|
||||
state,
|
||||
Action::Select {
|
||||
selection,
|
||||
mode: mode.into(),
|
||||
}
|
||||
),
|
||||
None => Outcome::failed("the ids are not UUIDs"),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_file_ids(ids: Vec<Uuid>) -> Vec<engine::FileId> {
|
||||
ids.into_iter().map(engine::FileId).collect()
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn select(state: State<AppState>, file_ids: Vec<Uuid>, mode: SelectMode) -> Outcome {
|
||||
let selection = engine::Selection::File {
|
||||
file_ids: to_file_ids(file_ids).into_iter().collect(),
|
||||
};
|
||||
select_elements(&state, Some(selection), mode)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn select_all(state: State<AppState>) -> Outcome {
|
||||
act!(state, Action::SelectAll)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn arrow_select(state: State<AppState>, down: bool, add: bool) -> Outcome {
|
||||
act!(state, Action::ArrowSelect { down, add })
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn select_tracks(
|
||||
state: State<AppState>,
|
||||
file_id: Uuid,
|
||||
track_ids: Vec<Uuid>,
|
||||
mode: SelectMode,
|
||||
) -> Outcome {
|
||||
let selection = engine::Selection::Track {
|
||||
file_id: engine::FileId(file_id),
|
||||
trk_ids: track_ids.into_iter().map(engine::TrackId).collect(),
|
||||
};
|
||||
select_elements(&state, Some(selection), mode)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn select_segments(
|
||||
state: State<AppState>,
|
||||
file_id: Uuid,
|
||||
track_id: Uuid,
|
||||
segment_ids: Vec<Uuid>,
|
||||
mode: SelectMode,
|
||||
) -> Outcome {
|
||||
let selection = engine::Selection::TrackSegment {
|
||||
file_id: engine::FileId(file_id),
|
||||
trk_id: engine::TrackId(track_id),
|
||||
trkseg_ids: segment_ids
|
||||
.into_iter()
|
||||
.map(engine::TrackSegmentId)
|
||||
.collect(),
|
||||
};
|
||||
select_elements(&state, Some(selection), mode)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn select_waypoint_group(state: State<AppState>, file_id: Uuid, mode: SelectMode) -> Outcome {
|
||||
let selection = engine::Selection::Waypoints {
|
||||
file_id: engine::FileId(file_id),
|
||||
};
|
||||
select_elements(&state, Some(selection), mode)
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn select_waypoints(
|
||||
state: State<AppState>,
|
||||
file_id: Uuid,
|
||||
waypoint_ids: Vec<Uuid>,
|
||||
mode: SelectMode,
|
||||
) -> Outcome {
|
||||
let selection = engine::Selection::Waypoint {
|
||||
file_id: engine::FileId(file_id),
|
||||
wpt_ids: waypoint_ids.into_iter().map(engine::WaypointId).collect(),
|
||||
};
|
||||
select_elements(&state, Some(selection), mode)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//! The statistics of the selection and the anchors of the routing tool, as little-endian arrays
|
||||
//! (one per call, copies of the buffers of the engine). Optional ones start with a byte telling
|
||||
//! whether they are there.
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use crate::dto::*;
|
||||
use crate::state::AppState;
|
||||
|
||||
macro_rules! f64_getters {
|
||||
($($name:ident),*) => {$(
|
||||
#[tauri::command(async)]
|
||||
pub fn $name(state: State<AppState>) -> Bytes {
|
||||
Bytes::new(state.read(|e| f64s(&e.statistics().$name)))
|
||||
}
|
||||
)*};
|
||||
}
|
||||
|
||||
f64_getters!(
|
||||
total_distance,
|
||||
slope,
|
||||
slope_segment_slope,
|
||||
slope_segment_distance,
|
||||
lng,
|
||||
lat,
|
||||
ele
|
||||
);
|
||||
|
||||
macro_rules! optional_f64_getters {
|
||||
($($name:ident),*) => {$(
|
||||
#[tauri::command(async)]
|
||||
pub fn $name(state: State<AppState>) -> Bytes {
|
||||
optional(state.read(|e| e.statistics().$name.as_deref().map(f64s)))
|
||||
}
|
||||
)*};
|
||||
}
|
||||
|
||||
optional_f64_getters!(speed, hr, cad, atemp, power);
|
||||
|
||||
/// Timestamps in milliseconds since the epoch (`NO_TIME` when missing), as `i64`.
|
||||
#[tauri::command(async)]
|
||||
pub fn timestamps(state: State<AppState>) -> Bytes {
|
||||
optional(state.read(|e| e.statistics().time.as_deref().map(i64s)))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn selection_statistics(state: State<AppState>) -> GlobalStatistics {
|
||||
state.read(|e| GlobalStatistics::new(&e.statistics().global))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn slice_statistics(
|
||||
state: State<AppState>,
|
||||
start: usize,
|
||||
end: usize,
|
||||
) -> Option<GlobalStatistics> {
|
||||
state.read(move |e| {
|
||||
e.statistics()
|
||||
.slice(start, end)
|
||||
.map(|s| GlobalStatistics::new(&s))
|
||||
})
|
||||
}
|
||||
|
||||
macro_rules! intervals_getters {
|
||||
($starts:ident, $values:ident, $field:ident) => {
|
||||
#[tauri::command(async)]
|
||||
pub fn $starts(state: State<AppState>) -> Bytes {
|
||||
Bytes::new(state.read(|e| u32s(&e.statistics().$field.starts)))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn $values(state: State<AppState>) -> Bytes {
|
||||
Bytes::new(state.read(|e| e.statistics().$field.values.clone()))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
intervals_getters!(surface_starts, surface_values, surface);
|
||||
intervals_getters!(highway_starts, highway_values, highway);
|
||||
intervals_getters!(sac_scale_starts, sac_scale_values, sac_scale);
|
||||
intervals_getters!(mtb_scale_starts, mtb_scale_values, mtb_scale);
|
||||
|
||||
/// The names of the categories, in the order of their codes. The lists only grow.
|
||||
#[tauri::command(async)]
|
||||
pub fn surfaces(state: State<AppState>) -> Vec<String> {
|
||||
state.read(|e| e.categories().surface.names().to_vec())
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn highways(state: State<AppState>) -> Vec<String> {
|
||||
state.read(|e| e.categories().highway.names().to_vec())
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn sac_scales(state: State<AppState>) -> Vec<String> {
|
||||
state.read(|e| e.categories().sac_scale.names().to_vec())
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn mtb_scales(state: State<AppState>) -> Vec<String> {
|
||||
state.read(|e| e.categories().mtb_scale.names().to_vec())
|
||||
}
|
||||
|
||||
// Routing buffers
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn anchor_indices(state: State<AppState>) -> Bytes {
|
||||
Bytes::new(state.read(|e| u32s(&e.routing().anchor_indices)))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn anchor_zooms(state: State<AppState>) -> Bytes {
|
||||
Bytes::new(state.read(|e| e.routing().anchor_zooms.clone()))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn segment_starts(state: State<AppState>) -> Bytes {
|
||||
Bytes::new(state.read(|e| u32s(&e.routing().segment_starts)))
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn segment_ids(state: State<AppState>) -> Vec<String> {
|
||||
state.read(|e| {
|
||||
e.routing()
|
||||
.segment_ids
|
||||
.iter()
|
||||
.map(|id| id.0.to_string())
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn routing_revision(state: State<AppState>) -> u32 {
|
||||
state.read(|e| e.routing().revision)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Keeping the files and the settings.
|
||||
//!
|
||||
//! For now nothing is written: the files only live as long as the app, and so do the settings.
|
||||
//! The file system storage will go here.
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::State;
|
||||
|
||||
use crate::dto::Outcome;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// What opening the storage found.
|
||||
#[derive(Serialize)]
|
||||
pub struct StorageOpened {
|
||||
settings: std::collections::BTreeMap<String, String>,
|
||||
outcome: Outcome,
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn open_storage(state: State<AppState>) -> StorageOpened {
|
||||
StorageOpened {
|
||||
settings: state.settings().clone(),
|
||||
outcome: Outcome::failed_none(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves when what was changed has been written to the storage.
|
||||
#[tauri::command(async)]
|
||||
pub fn flush_storage() {}
|
||||
|
||||
/// Reads the files again if they were written by someone else.
|
||||
#[tauri::command(async)]
|
||||
pub fn sync_storage() -> Outcome {
|
||||
Outcome::failed_none()
|
||||
}
|
||||
|
||||
/// Saves a setting, `value` being its JSON.
|
||||
#[tauri::command(async)]
|
||||
pub fn set_setting(state: State<AppState>, key: String, value: String) {
|
||||
state.settings().insert(key, value);
|
||||
}
|
||||
|
||||
#[tauri::command(async)]
|
||||
pub fn delete_setting(state: State<AppState>, key: String) {
|
||||
state.settings().remove(&key);
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
//! What crosses to the frontend, as JSON: the same shapes as the objects of the WASM interface
|
||||
//! (`gpx-rs/wasm/src/ts.rs` holds their TypeScript types).
|
||||
|
||||
use gpx_engine::{self as engine, FileId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Bytes that cross as they are: typed arrays (little-endian), GPX files.
|
||||
pub type Bytes = tauri::ipc::Response;
|
||||
|
||||
pub fn f64s(values: &[f64]) -> Vec<u8> {
|
||||
values.iter().flat_map(|v| v.to_le_bytes()).collect()
|
||||
}
|
||||
|
||||
pub fn i64s(values: &[i64]) -> Vec<u8> {
|
||||
values.iter().flat_map(|v| v.to_le_bytes()).collect()
|
||||
}
|
||||
|
||||
pub fn u32s(values: &[u32]) -> Vec<u8> {
|
||||
values.iter().flat_map(|v| v.to_le_bytes()).collect()
|
||||
}
|
||||
|
||||
/// An array that can be absent: the first byte tells whether it is there, the array follows.
|
||||
pub fn optional(array: Option<Vec<u8>>) -> Bytes {
|
||||
let mut bytes = vec![u8::from(array.is_some())];
|
||||
bytes.extend(array.unwrap_or_default());
|
||||
Bytes::new(bytes)
|
||||
}
|
||||
|
||||
pub fn ids(items: &[FileId]) -> Vec<String> {
|
||||
items.iter().map(|id| id.0.to_string()).collect()
|
||||
}
|
||||
|
||||
// Outcome
|
||||
|
||||
/// What a call that edits the files or changes the selection did.
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Outcome {
|
||||
changed: bool,
|
||||
order_changed: bool,
|
||||
selection_changed: bool,
|
||||
clipboard_changed: bool,
|
||||
added: Vec<String>,
|
||||
modified: Vec<String>,
|
||||
removed: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
impl Outcome {
|
||||
pub fn new(outcome: &engine::Outcome, error: Option<String>) -> Self {
|
||||
let diff = outcome.diff.clone().unwrap_or_default();
|
||||
Self {
|
||||
changed: outcome.changed,
|
||||
order_changed: outcome.order_changed,
|
||||
selection_changed: outcome.selection_changed,
|
||||
clipboard_changed: outcome.clipboard_changed,
|
||||
added: ids(&diff.added),
|
||||
modified: ids(&diff.modified),
|
||||
removed: ids(&diff.removed),
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
/// The outcome of a call that did nothing and has nothing to say.
|
||||
pub fn failed_none() -> Self {
|
||||
Self::new(&engine::Outcome::default(), None)
|
||||
}
|
||||
|
||||
/// What a call that does nothing returns.
|
||||
pub fn failed(message: impl Into<String>) -> Self {
|
||||
Self::new(&engine::Outcome::default(), Some(message.into()))
|
||||
}
|
||||
}
|
||||
|
||||
// Selection and clipboard
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(
|
||||
tag = "type",
|
||||
rename_all = "camelCase",
|
||||
rename_all_fields = "camelCase"
|
||||
)]
|
||||
pub enum Selection {
|
||||
Empty,
|
||||
File {
|
||||
file_ids: Vec<Uuid>,
|
||||
},
|
||||
Track {
|
||||
file_id: Uuid,
|
||||
track_ids: Vec<Uuid>,
|
||||
},
|
||||
Segment {
|
||||
file_id: Uuid,
|
||||
track_id: Uuid,
|
||||
segment_ids: Vec<Uuid>,
|
||||
},
|
||||
Waypoints {
|
||||
file_id: Uuid,
|
||||
},
|
||||
Waypoint {
|
||||
file_id: Uuid,
|
||||
waypoint_ids: Vec<Uuid>,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<&engine::Selection> for Selection {
|
||||
fn from(selection: &engine::Selection) -> Self {
|
||||
use engine::Selection as S;
|
||||
match selection {
|
||||
S::Empty => Self::Empty,
|
||||
S::File { file_ids } => Self::File {
|
||||
file_ids: file_ids.iter().map(|id| id.0).collect(),
|
||||
},
|
||||
S::Track { file_id, trk_ids } => Self::Track {
|
||||
file_id: file_id.0,
|
||||
track_ids: trk_ids.iter().map(|id| id.0).collect(),
|
||||
},
|
||||
S::TrackSegment {
|
||||
file_id,
|
||||
trk_id,
|
||||
trkseg_ids,
|
||||
} => Self::Segment {
|
||||
file_id: file_id.0,
|
||||
track_id: trk_id.0,
|
||||
segment_ids: trkseg_ids.iter().map(|id| id.0).collect(),
|
||||
},
|
||||
S::Waypoints { file_id } => Self::Waypoints { file_id: file_id.0 },
|
||||
S::Waypoint { file_id, wpt_ids } => Self::Waypoint {
|
||||
file_id: file_id.0,
|
||||
waypoint_ids: wpt_ids.iter().map(|id| id.0).collect(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Selection> for engine::Selection {
|
||||
fn from(selection: Selection) -> Self {
|
||||
use engine::Selection as S;
|
||||
match selection {
|
||||
Selection::Empty => S::Empty,
|
||||
Selection::File { file_ids } => S::File {
|
||||
file_ids: file_ids.into_iter().map(FileId).collect(),
|
||||
},
|
||||
Selection::Track { file_id, track_ids } => S::Track {
|
||||
file_id: FileId(file_id),
|
||||
trk_ids: track_ids.into_iter().map(engine::TrackId).collect(),
|
||||
},
|
||||
Selection::Segment {
|
||||
file_id,
|
||||
track_id,
|
||||
segment_ids,
|
||||
} => S::TrackSegment {
|
||||
file_id: FileId(file_id),
|
||||
trk_id: engine::TrackId(track_id),
|
||||
trkseg_ids: segment_ids
|
||||
.into_iter()
|
||||
.map(engine::TrackSegmentId)
|
||||
.collect(),
|
||||
},
|
||||
Selection::Waypoints { file_id } => S::Waypoints {
|
||||
file_id: FileId(file_id),
|
||||
},
|
||||
Selection::Waypoint {
|
||||
file_id,
|
||||
waypoint_ids,
|
||||
} => S::Waypoint {
|
||||
file_id: FileId(file_id),
|
||||
wpt_ids: waypoint_ids.into_iter().map(engine::WaypointId).collect(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where moved elements go. A negative index is the start, an infinite one the end (which JSON
|
||||
/// writes as `null`, so the frontend sends a very large number instead).
|
||||
#[derive(Deserialize)]
|
||||
#[serde(
|
||||
tag = "type",
|
||||
rename_all = "camelCase",
|
||||
rename_all_fields = "camelCase"
|
||||
)]
|
||||
pub enum MoveTarget {
|
||||
Files {
|
||||
index: f64,
|
||||
},
|
||||
Tracks {
|
||||
file_id: Uuid,
|
||||
index: f64,
|
||||
},
|
||||
Segments {
|
||||
file_id: Uuid,
|
||||
track_id: Uuid,
|
||||
index: f64,
|
||||
},
|
||||
Waypoints {
|
||||
file_id: Uuid,
|
||||
index: f64,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<MoveTarget> for engine::MoveTarget {
|
||||
fn from(target: MoveTarget) -> Self {
|
||||
use engine::MoveTarget as T;
|
||||
let index = |index: f64| index.max(0.0) as usize;
|
||||
match target {
|
||||
MoveTarget::Files { index: i } => T::Files { index: index(i) },
|
||||
MoveTarget::Tracks { file_id, index: i } => T::Tracks {
|
||||
file_id: FileId(file_id),
|
||||
index: index(i),
|
||||
},
|
||||
MoveTarget::Segments {
|
||||
file_id,
|
||||
track_id,
|
||||
index: i,
|
||||
} => T::Segments {
|
||||
file_id: FileId(file_id),
|
||||
trk_id: engine::TrackId(track_id),
|
||||
index: index(i),
|
||||
},
|
||||
MoveTarget::Waypoints { file_id, index: i } => T::Waypoints {
|
||||
file_id: FileId(file_id),
|
||||
index: index(i),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Clipboard {
|
||||
#[serde(rename = "type")]
|
||||
kind: &'static str,
|
||||
ids: Vec<String>,
|
||||
cut: bool,
|
||||
}
|
||||
|
||||
impl Clipboard {
|
||||
pub fn new(clipboard: &engine::Clipboard) -> Self {
|
||||
let strings = |ids: Vec<Uuid>| ids.iter().map(Uuid::to_string).collect();
|
||||
let (kind, ids) = match clipboard.content.ids() {
|
||||
engine::ClipboardIds::Files(ids) => {
|
||||
("files", strings(ids.into_iter().map(|id| id.0).collect()))
|
||||
}
|
||||
engine::ClipboardIds::Tracks(ids) => {
|
||||
("tracks", strings(ids.into_iter().map(|id| id.0).collect()))
|
||||
}
|
||||
engine::ClipboardIds::Segments(ids) => (
|
||||
"segments",
|
||||
strings(ids.into_iter().map(|id| id.0).collect()),
|
||||
),
|
||||
engine::ClipboardIds::Waypoints(ids) => (
|
||||
"waypoints",
|
||||
strings(ids.into_iter().map(|id| id.0).collect()),
|
||||
),
|
||||
};
|
||||
Self {
|
||||
kind,
|
||||
ids,
|
||||
cut: clipboard.cut,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Files
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileStructure {
|
||||
id: String,
|
||||
name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
desc: Option<String>,
|
||||
tracks: Vec<TrackNode>,
|
||||
waypoints: Vec<WaypointNode>,
|
||||
waypoints_rev: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TrackNode {
|
||||
id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
desc: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
color: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
opacity: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
width: Option<f64>,
|
||||
segments: Vec<SegmentNode>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SegmentNode {
|
||||
id: String,
|
||||
rev: String,
|
||||
length: f64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct WaypointNode {
|
||||
id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
sym: Option<String>,
|
||||
}
|
||||
|
||||
impl FileStructure {
|
||||
pub fn new(file: &engine::FileStructure) -> Self {
|
||||
Self {
|
||||
id: file.id.0.to_string(),
|
||||
name: file.name.clone(),
|
||||
desc: file.desc.clone(),
|
||||
tracks: file
|
||||
.tracks
|
||||
.iter()
|
||||
.map(|trk| TrackNode {
|
||||
id: trk.id.0.to_string(),
|
||||
name: trk.name.clone(),
|
||||
desc: trk.desc.clone(),
|
||||
color: trk.color.clone(),
|
||||
opacity: trk.opacity,
|
||||
width: trk.width,
|
||||
segments: trk
|
||||
.segments
|
||||
.iter()
|
||||
.map(|seg| SegmentNode {
|
||||
id: seg.id.0.to_string(),
|
||||
rev: seg.rev_id.0.to_string(),
|
||||
length: seg.len as f64,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
waypoints: file
|
||||
.waypoints
|
||||
.iter()
|
||||
.map(|wpt| WaypointNode {
|
||||
id: wpt.id.0.to_string(),
|
||||
name: wpt.name.clone(),
|
||||
sym: wpt.sym.clone(),
|
||||
})
|
||||
.collect(),
|
||||
waypoints_rev: file.wpt_rev_id.0.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GlobalStatistics {
|
||||
total_distance: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
moving_distance: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
total_time: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
moving_time: Option<f64>,
|
||||
elevation_gain: f64,
|
||||
elevation_loss: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
start_time: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
end_time: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
total_speed: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
moving_speed: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
bounds: Option<Bounds>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Bounds {
|
||||
west: f64,
|
||||
south: f64,
|
||||
east: f64,
|
||||
north: f64,
|
||||
}
|
||||
|
||||
impl GlobalStatistics {
|
||||
pub fn new(stats: &engine::GlobalStatistics) -> Self {
|
||||
let seconds = |ms: Option<i64>| ms.map(|ms| ms as f64 / 1000.0);
|
||||
let (sw, ne) = (&stats.bounds.sw, &stats.bounds.ne);
|
||||
Self {
|
||||
total_distance: stats.total_distance,
|
||||
moving_distance: stats.moving_distance,
|
||||
total_time: seconds(stats.total_time),
|
||||
moving_time: seconds(stats.moving_time),
|
||||
elevation_gain: stats.elevation_gain,
|
||||
elevation_loss: stats.elevation_loss,
|
||||
start_time: stats.start_time.map(|t| t as f64),
|
||||
end_time: stats.end_time.map(|t| t as f64),
|
||||
total_speed: stats.total_speed(),
|
||||
moving_speed: stats.moving_speed(),
|
||||
bounds: (sw.lng <= ne.lng && sw.lat <= ne.lat).then_some(Bounds {
|
||||
west: sw.lng,
|
||||
south: sw.lat,
|
||||
east: ne.lng,
|
||||
north: ne.lat,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct WaypointDetails {
|
||||
id: String,
|
||||
lng: f64,
|
||||
lat: f64,
|
||||
ele: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
time: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
desc: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
cmt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
sym: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
links: Option<Vec<Link>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Link {
|
||||
href: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
text: Option<String>,
|
||||
}
|
||||
|
||||
impl WaypointDetails {
|
||||
pub fn new(wpt: &engine::Waypoint) -> Self {
|
||||
Self {
|
||||
id: wpt.id.0.to_string(),
|
||||
lng: wpt.coordinates.lng,
|
||||
lat: wpt.coordinates.lat,
|
||||
ele: wpt.ele,
|
||||
time: wpt.time.map(|t| t as f64),
|
||||
name: wpt.name.clone(),
|
||||
desc: wpt.desc.clone(),
|
||||
cmt: wpt.cmt.clone(),
|
||||
sym: wpt.sym.clone(),
|
||||
links: (!wpt.links.is_empty()).then(|| {
|
||||
wpt.links
|
||||
.iter()
|
||||
.map(|link| Link {
|
||||
href: link.href.clone(),
|
||||
text: link.text.clone(),
|
||||
})
|
||||
.collect()
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TrackpointDetails {
|
||||
lng: f64,
|
||||
lat: f64,
|
||||
ele: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
time: Option<f64>,
|
||||
}
|
||||
|
||||
impl TrackpointDetails {
|
||||
pub fn new(pt: &engine::Trackpoint) -> Self {
|
||||
Self {
|
||||
lng: pt.coordinates.lng,
|
||||
lat: pt.coordinates.lat,
|
||||
ele: pt.ele,
|
||||
time: pt.time.map(|t| t as f64),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enumerations
|
||||
|
||||
#[derive(Clone, Copy, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SelectMode {
|
||||
Replace,
|
||||
Add,
|
||||
Toggle,
|
||||
}
|
||||
|
||||
impl From<SelectMode> for engine::SelectMode {
|
||||
fn from(m: SelectMode) -> Self {
|
||||
match m {
|
||||
SelectMode::Replace => Self::Replace,
|
||||
SelectMode::Add => Self::Add,
|
||||
SelectMode::Toggle => Self::Toggle,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum MergeType {
|
||||
Connect,
|
||||
Group,
|
||||
}
|
||||
|
||||
impl From<MergeType> for engine::MergeType {
|
||||
fn from(t: MergeType) -> Self {
|
||||
match t {
|
||||
MergeType::Connect => Self::Connect,
|
||||
MergeType::Group => Self::Group,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SplitType {
|
||||
Files,
|
||||
Tracks,
|
||||
Segments,
|
||||
}
|
||||
|
||||
impl From<SplitType> for engine::SplitType {
|
||||
fn from(t: SplitType) -> Self {
|
||||
match t {
|
||||
SplitType::Files => Self::Files,
|
||||
SplitType::Tracks => Self::Tracks,
|
||||
SplitType::Segments => Self::Segments,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum CleanType {
|
||||
Inside,
|
||||
Outside,
|
||||
}
|
||||
|
||||
impl From<CleanType> for engine::CleanType {
|
||||
fn from(t: CleanType) -> Self {
|
||||
match t {
|
||||
CleanType::Inside => Self::Inside,
|
||||
CleanType::Outside => Self::Outside,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Routing
|
||||
|
||||
/// A category of the trackpoints of a route, as in `RouteCategory` of the engine.
|
||||
#[derive(Default, Deserialize)]
|
||||
pub struct RouteCategory {
|
||||
pub starts: Vec<u32>,
|
||||
pub values: Vec<u8>,
|
||||
pub names: Vec<String>,
|
||||
}
|
||||
|
||||
impl RouteCategory {
|
||||
pub fn view(&self) -> engine::RouteCategory<'_> {
|
||||
engine::RouteCategory {
|
||||
starts: &self.starts,
|
||||
values: &self.values,
|
||||
names: &self.names,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct RouteAttributes {
|
||||
pub surface: RouteCategory,
|
||||
pub highway: RouteCategory,
|
||||
pub sac_scale: RouteCategory,
|
||||
pub mtb_scale: RouteCategory,
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//! The desktop shell of gpx.studio. The engine of the files (`gpx-rs/engine`) runs in this
|
||||
//! process, and the frontend talks to it through the commands of `commands`.
|
||||
|
||||
mod commands;
|
||||
mod dto;
|
||||
mod state;
|
||||
|
||||
use commands::*;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
with_engine(tauri::Builder::default())
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running gpx.studio");
|
||||
}
|
||||
|
||||
/// Gives the builder the engine and the commands that reach it.
|
||||
fn with_engine<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::Builder<R> {
|
||||
builder
|
||||
.manage(state::AppState::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// selection
|
||||
selection::select,
|
||||
selection::select_all,
|
||||
selection::arrow_select,
|
||||
selection::select_tracks,
|
||||
selection::select_segments,
|
||||
selection::select_waypoint_group,
|
||||
selection::select_waypoints,
|
||||
// files
|
||||
files::new_file,
|
||||
files::load_files,
|
||||
files::duplicate,
|
||||
files::delete,
|
||||
files::delete_all,
|
||||
files::reorder,
|
||||
files::copy,
|
||||
files::cut,
|
||||
files::paste,
|
||||
files::move_elements,
|
||||
files::undo,
|
||||
files::redo,
|
||||
// edits and tools
|
||||
edit::metadata,
|
||||
edit::style,
|
||||
edit::new_track,
|
||||
edit::new_track_segment,
|
||||
edit::new_waypoint,
|
||||
edit::move_waypoint,
|
||||
edit::delete_waypoint,
|
||||
edit::update_waypoint,
|
||||
edit::reverse,
|
||||
edit::round_trip,
|
||||
edit::crop,
|
||||
edit::reduce,
|
||||
edit::change_timestamps,
|
||||
edit::create_artificial_timestamps,
|
||||
edit::merge,
|
||||
edit::extract,
|
||||
edit::elevation,
|
||||
edit::clean,
|
||||
routing::route,
|
||||
routing::insert_anchor,
|
||||
routing::change_loop_start,
|
||||
routing::split,
|
||||
// queries
|
||||
queries::file_order,
|
||||
queries::file_structure,
|
||||
queries::file_statistics,
|
||||
queries::selection,
|
||||
queries::clipboard,
|
||||
queries::can_undo,
|
||||
queries::can_redo,
|
||||
queries::can_paste,
|
||||
queries::waypoint,
|
||||
queries::trackpoint,
|
||||
queries::export_file,
|
||||
queries::exportable_data,
|
||||
queries::reduction_distances,
|
||||
queries::segment_coordinates,
|
||||
queries::waypoint_coordinates,
|
||||
// statistics
|
||||
statistics::total_distance,
|
||||
statistics::slope,
|
||||
statistics::slope_segment_slope,
|
||||
statistics::slope_segment_distance,
|
||||
statistics::lng,
|
||||
statistics::lat,
|
||||
statistics::ele,
|
||||
statistics::speed,
|
||||
statistics::hr,
|
||||
statistics::cad,
|
||||
statistics::atemp,
|
||||
statistics::power,
|
||||
statistics::timestamps,
|
||||
statistics::selection_statistics,
|
||||
statistics::slice_statistics,
|
||||
statistics::surface_starts,
|
||||
statistics::surface_values,
|
||||
statistics::highway_starts,
|
||||
statistics::highway_values,
|
||||
statistics::sac_scale_starts,
|
||||
statistics::sac_scale_values,
|
||||
statistics::mtb_scale_starts,
|
||||
statistics::mtb_scale_values,
|
||||
statistics::surfaces,
|
||||
statistics::highways,
|
||||
statistics::sac_scales,
|
||||
statistics::mtb_scales,
|
||||
statistics::anchor_indices,
|
||||
statistics::anchor_zooms,
|
||||
statistics::segment_starts,
|
||||
statistics::segment_ids,
|
||||
statistics::routing_revision,
|
||||
// storage
|
||||
storage::open_storage,
|
||||
storage::flush_storage,
|
||||
storage::sync_storage,
|
||||
storage::set_setting,
|
||||
storage::delete_setting,
|
||||
])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents an additional console window on Windows in release.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
gpx_studio_desktop_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//! The state the commands share: the engine, and the settings of the frontend.
|
||||
//!
|
||||
//! The engine is not `Send` (it shares its data with `Rc`), so it lives on a thread of its own and
|
||||
//! the commands send it jobs: they are done one after the other, in the order they were sent.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::mpsc::{self, Sender};
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
use std::thread;
|
||||
|
||||
use gpx_engine::{Action, Engine};
|
||||
|
||||
use crate::dto::Outcome;
|
||||
|
||||
type Job = Box<dyn FnOnce(&mut Engine) + Send>;
|
||||
|
||||
/// What the commands talk to. The engine is the same as in the browser (`gpx-rs`); here it lives in
|
||||
/// the process of the app, and the frontend reaches it through the commands only.
|
||||
pub struct AppState {
|
||||
jobs: Mutex<Sender<Job>>,
|
||||
/// The settings of the frontend, as JSON strings by key.
|
||||
settings: Mutex<BTreeMap<String, String>>,
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
fn default() -> Self {
|
||||
let (jobs, receiver) = mpsc::channel::<Job>();
|
||||
thread::Builder::new()
|
||||
.name("engine".into())
|
||||
.spawn(move || {
|
||||
let mut engine = Engine::default();
|
||||
while let Ok(job) = receiver.recv() {
|
||||
job(&mut engine);
|
||||
}
|
||||
})
|
||||
.expect("the engine thread could not be started");
|
||||
Self {
|
||||
jobs: Mutex::new(jobs),
|
||||
settings: Mutex::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// Runs `f` on the engine and waits for its result.
|
||||
pub fn run<T: Send + 'static>(&self, f: impl FnOnce(&mut Engine) -> T + Send + 'static) -> T {
|
||||
let (reply, result) = mpsc::channel();
|
||||
let job: Job = Box::new(move |engine| {
|
||||
let _ = reply.send(f(engine));
|
||||
});
|
||||
self.jobs
|
||||
.lock()
|
||||
.unwrap()
|
||||
.send(job)
|
||||
.expect("the engine thread stopped");
|
||||
result.recv().expect("the engine thread stopped")
|
||||
}
|
||||
|
||||
/// Reads from the engine.
|
||||
pub fn read<T: Send + 'static>(&self, f: impl FnOnce(&Engine) -> T + Send + 'static) -> T {
|
||||
self.run(move |engine| f(engine))
|
||||
}
|
||||
|
||||
pub fn settings(&self) -> MutexGuard<'_, BTreeMap<String, String>> {
|
||||
self.settings.lock().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes an action and tells what it did, see `Outcome`.
|
||||
pub fn execute(engine: &mut Engine, action: Action) -> Outcome {
|
||||
match engine.execute(action) {
|
||||
Ok(outcome) => Outcome::new(&outcome, None),
|
||||
Err(error) => Outcome::failed(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes an action on the engine. The values the action uses are moved to the engine thread.
|
||||
macro_rules! act {
|
||||
($state:expr, $action:expr) => {
|
||||
$state.run(move |engine| $crate::state::execute(engine, $action))
|
||||
};
|
||||
}
|
||||
|
||||
/// Executes a command (the command can borrow what it uses).
|
||||
macro_rules! edit {
|
||||
($state:expr, $command:expr) => {
|
||||
act!($state, ::gpx_engine::Action::Edit($command))
|
||||
};
|
||||
}
|
||||
|
||||
/// Like `edit!`, but does nothing if the selection is not the one of `revision` anymore (the
|
||||
/// indices of the routing buffers only mean something for their revision).
|
||||
macro_rules! edit_at {
|
||||
($state:expr, $revision:expr, $command:expr) => {{
|
||||
let revision = $revision;
|
||||
$state.run(move |engine| {
|
||||
if engine.routing().revision != revision {
|
||||
return $crate::dto::Outcome::failed(
|
||||
"the selection changed since the routing buffers were read",
|
||||
);
|
||||
}
|
||||
$crate::state::execute(engine, ::gpx_engine::Action::Edit($command))
|
||||
})
|
||||
}};
|
||||
}
|
||||
|
||||
pub(crate) use {act, edit, edit_at};
|
||||
@@ -0,0 +1,274 @@
|
||||
//! The commands, called the way the frontend calls them (through the IPC of Tauri), on the sample
|
||||
//! files of the engine.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use tauri::test::{get_ipc_response, mock_builder, mock_context, noop_assets, INVOKE_KEY};
|
||||
use tauri::webview::InvokeRequest;
|
||||
use tauri::{ipc::CallbackFn, ipc::InvokeBody, ipc::InvokeResponseBody, WebviewWindow};
|
||||
|
||||
struct App(WebviewWindow<tauri::test::MockRuntime>);
|
||||
|
||||
impl App {
|
||||
fn new() -> Self {
|
||||
let app = crate::with_engine(mock_builder())
|
||||
.build(mock_context(noop_assets()))
|
||||
.unwrap();
|
||||
let window = tauri::WebviewWindowBuilder::new(&app, "main", Default::default())
|
||||
.build()
|
||||
.unwrap();
|
||||
// the app has to outlive its window
|
||||
std::mem::forget(app);
|
||||
Self(window)
|
||||
}
|
||||
|
||||
fn request(
|
||||
&self,
|
||||
cmd: &str,
|
||||
body: InvokeBody,
|
||||
headers: tauri::http::HeaderMap,
|
||||
) -> InvokeResponseBody {
|
||||
get_ipc_response(
|
||||
&self.0,
|
||||
InvokeRequest {
|
||||
cmd: cmd.into(),
|
||||
callback: CallbackFn(0),
|
||||
error: CallbackFn(1),
|
||||
url: "tauri://localhost".parse().unwrap(),
|
||||
body,
|
||||
headers,
|
||||
invoke_key: INVOKE_KEY.to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap_or_else(|error| panic!("{cmd} failed: {error}"))
|
||||
}
|
||||
|
||||
fn call(&self, cmd: &str, args: Value) -> Value {
|
||||
match self.request(cmd, InvokeBody::Json(args), Default::default()) {
|
||||
InvokeResponseBody::Json(json) => serde_json::from_str(&json).unwrap(),
|
||||
InvokeResponseBody::Raw(_) => panic!("{cmd} returned bytes"),
|
||||
}
|
||||
}
|
||||
|
||||
fn bytes(&self, cmd: &str, args: Value) -> Vec<u8> {
|
||||
match self.request(cmd, InvokeBody::Json(args), Default::default()) {
|
||||
InvokeResponseBody::Raw(bytes) => bytes,
|
||||
InvokeResponseBody::Json(_) => panic!("{cmd} returned JSON"),
|
||||
}
|
||||
}
|
||||
|
||||
fn load(&self, names: &[&str]) -> Value {
|
||||
let mut data = vec![];
|
||||
let mut lengths = vec![];
|
||||
for name in names {
|
||||
let file = std::fs::read(format!("../gpx-rs/engine/data/{name}.gpx")).unwrap();
|
||||
lengths.push(file.len());
|
||||
data.extend(file);
|
||||
}
|
||||
let header = |value: Value| value.to_string().replace('"', "%22").parse().unwrap();
|
||||
let mut headers = tauri::http::HeaderMap::new();
|
||||
headers.insert("lengths", header(json!(lengths)));
|
||||
headers.insert("names", header(json!(names)));
|
||||
match self.request("load_files", InvokeBody::Raw(data), headers) {
|
||||
InvokeResponseBody::Json(json) => serde_json::from_str(&json).unwrap(),
|
||||
InvokeResponseBody::Raw(_) => panic!("load_files returned bytes"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn f64s(bytes: &[u8]) -> Vec<f64> {
|
||||
bytes
|
||||
.chunks_exact(8)
|
||||
.map(|chunk| f64::from_le_bytes(chunk.try_into().unwrap()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_files_and_describes_them() {
|
||||
let app = App::new();
|
||||
let outcome = app.load(&["simple", "with_tracks"]);
|
||||
assert_eq!(outcome["changed"], true);
|
||||
assert_eq!(outcome["added"].as_array().unwrap().len(), 2);
|
||||
assert!(outcome.get("error").is_none());
|
||||
|
||||
let order = app.call("file_order", json!({}));
|
||||
let first = order[0].as_str().unwrap();
|
||||
assert_eq!(
|
||||
app.call("selection", json!({})),
|
||||
json!({ "type": "file", "fileIds": [first] })
|
||||
);
|
||||
|
||||
let structure = app.call("file_structure", json!({ "fileId": first }));
|
||||
assert_eq!(structure["name"], "simple");
|
||||
let segment = &structure["tracks"][0]["segments"][0];
|
||||
let length = segment["length"].as_f64().unwrap() as usize;
|
||||
assert!(length > 0);
|
||||
|
||||
// coordinates are little-endian doubles, lng and lat for each trackpoint
|
||||
let coordinates =
|
||||
f64s(&app.bytes("segment_coordinates", json!({ "segmentId": segment["id"] })));
|
||||
assert_eq!(coordinates.len(), 2 * length);
|
||||
|
||||
let statistics = app.call("file_statistics", json!({ "fileId": first }));
|
||||
assert!(statistics["totalDistance"].as_f64().unwrap() > 0.0);
|
||||
assert!(
|
||||
statistics["bounds"]["west"].as_f64().unwrap()
|
||||
<= statistics["bounds"]["east"].as_f64().unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
app.call(
|
||||
"file_structure",
|
||||
json!({ "fileId": "00000000-0000-0000-0000-000000000000" })
|
||||
),
|
||||
Value::Null
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_statistics_of_the_selection() {
|
||||
let app = App::new();
|
||||
app.load(&["simple"]);
|
||||
let distance = f64s(&app.bytes("total_distance", json!({})));
|
||||
assert!(distance.len() > 1);
|
||||
assert_eq!(f64s(&app.bytes("lng", json!({}))).len(), distance.len());
|
||||
assert!(
|
||||
app.call("selection_statistics", json!({}))["totalDistance"]
|
||||
.as_f64()
|
||||
.unwrap()
|
||||
> 0.0
|
||||
);
|
||||
// optional arrays start with a byte telling whether they are there
|
||||
let hr = app.bytes("hr", json!({}));
|
||||
assert_eq!(hr[0], 0);
|
||||
assert!(
|
||||
app.call("slice_statistics", json!({ "start": 1, "end": 3 }))["totalDistance"].is_number()
|
||||
);
|
||||
assert_eq!(
|
||||
app.call("slice_statistics", json!({ "start": 0, "end": 100000 })),
|
||||
Value::Null
|
||||
);
|
||||
|
||||
app.load(&["with_hr"]);
|
||||
app.call("select_all", json!({}));
|
||||
let hr = app.bytes("hr", json!({}));
|
||||
assert_eq!(hr[0], 1);
|
||||
assert_eq!((hr.len() - 1) % 8, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_undoes_and_selects() {
|
||||
let app = App::new();
|
||||
let outcome = app.call(
|
||||
"new_file",
|
||||
json!({ "name": "route", "lng": 4.4, "lat": 50.8 }),
|
||||
);
|
||||
assert_eq!(outcome["changed"], true);
|
||||
assert_eq!(app.call("can_undo", json!({})), true);
|
||||
|
||||
let id = app.call("file_order", json!({}))[0].clone();
|
||||
let outcome = app.call("metadata", json!({ "name": "renamed", "desc": "" }));
|
||||
assert_eq!(outcome["modified"], json!([id]));
|
||||
assert_eq!(
|
||||
app.call("file_structure", json!({ "fileId": id }))["name"],
|
||||
"renamed"
|
||||
);
|
||||
|
||||
let outcome = app.call("select", json!({ "fileIds": [], "mode": "replace" }));
|
||||
assert_eq!(outcome["selectionChanged"], true);
|
||||
assert_eq!(app.call("selection", json!({})), json!({ "type": "empty" }));
|
||||
app.call("select", json!({ "fileIds": [id], "mode": "toggle" }));
|
||||
|
||||
app.call("undo", json!({}));
|
||||
assert_eq!(
|
||||
app.call("file_structure", json!({ "fileId": id }))["name"],
|
||||
"route"
|
||||
);
|
||||
assert_eq!(app.call("can_redo", json!({})), true);
|
||||
app.call("undo", json!({}));
|
||||
assert_eq!(app.call("file_order", json!({})), json!([]));
|
||||
|
||||
// what cannot be done says why
|
||||
let outcome = app.call("reverse", json!({}));
|
||||
assert_eq!(outcome["changed"], false);
|
||||
assert!(outcome["error"].is_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moves_and_clipboard() {
|
||||
let app = App::new();
|
||||
app.load(&["simple", "with_tracks"]);
|
||||
let ids = app.call("file_order", json!({}));
|
||||
app.call("select", json!({ "fileIds": [ids[0]], "mode": "replace" }));
|
||||
app.call("copy", json!({}));
|
||||
assert_eq!(
|
||||
app.call("clipboard", json!({})),
|
||||
json!({ "type": "files", "ids": [ids[0]], "cut": false })
|
||||
);
|
||||
|
||||
let outcome = app.call(
|
||||
"move_elements",
|
||||
json!({
|
||||
"what": { "type": "file", "fileIds": [ids[0]] },
|
||||
"to": { "type": "files", "index": 9007199254740991u64 },
|
||||
}),
|
||||
);
|
||||
assert_eq!(outcome["orderChanged"], true);
|
||||
assert_eq!(app.call("file_order", json!({})), json!([ids[1], ids[0]]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waypoints_exports_and_routing_revisions() {
|
||||
let app = App::new();
|
||||
app.call("new_file", json!({ "name": "file" }));
|
||||
let outcome = app.call(
|
||||
"new_waypoint",
|
||||
json!({ "lng": 4.4, "lat": 50.8, "ele": 100, "name": "summit", "desc": "", "icon": "Summit", "link": "https://example.com" }),
|
||||
);
|
||||
assert_eq!(outcome["changed"], true);
|
||||
let file = app.call("file_order", json!({}))[0].clone();
|
||||
let waypoint =
|
||||
app.call("file_structure", json!({ "fileId": file }))["waypoints"][0]["id"].clone();
|
||||
let details = app.call(
|
||||
"waypoint",
|
||||
json!({ "fileId": file, "waypointId": waypoint }),
|
||||
);
|
||||
assert_eq!(details["name"], "summit");
|
||||
assert_eq!(details["sym"], "Summit");
|
||||
assert_eq!(details["links"][0]["href"], "https://example.com");
|
||||
|
||||
let exported = app.bytes(
|
||||
"export_file",
|
||||
json!({ "fileId": file, "time": true, "hr": true, "cad": true, "atemp": true, "power": true, "osm": true, "asRoute": false }),
|
||||
);
|
||||
assert_eq!(exported[0], 1);
|
||||
assert!(String::from_utf8(exported[1..].to_vec())
|
||||
.unwrap()
|
||||
.contains("<wpt"));
|
||||
assert_eq!(app.call("exportable_data", json!({ "fileIds": [file] })), 0);
|
||||
|
||||
let stale = app.call(
|
||||
"route",
|
||||
json!({
|
||||
"revision": 12345, "start": 0, "end": 0,
|
||||
"lng": [4.4], "lat": [50.8], "ele": [0],
|
||||
"attributes": { "surface": { "starts": [0], "values": [0], "names": [] } },
|
||||
"anchors": [],
|
||||
}),
|
||||
);
|
||||
assert_eq!(stale["changed"], false);
|
||||
assert!(stale["error"].as_str().unwrap().contains("routing buffers"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_settings_for_the_session() {
|
||||
let app = App::new();
|
||||
app.call(
|
||||
"set_setting",
|
||||
json!({ "key": "units", "value": "\"metric\"" }),
|
||||
);
|
||||
assert_eq!(
|
||||
app.call("open_storage", json!({}))["settings"]["units"],
|
||||
"\"metric\""
|
||||
);
|
||||
app.call("delete_setting", json!({ "key": "units" }));
|
||||
assert_eq!(app.call("open_storage", json!({}))["settings"], json!({}));
|
||||
}
|
||||
Reference in new issue
Block a user