78 Commits
Author SHA1 Message Date
vcoppe 45f877ec78 route export, filter out anchors at high zoom level 2026-10-26 19:27:12 +01:00
vcoppe 0f957fb1f2 create route file action 2026-10-26 19:22:14 +01:00
vcoppe 764d0b233a route - track conversion 2026-10-26 19:06:19 +01:00
vcoppe b6c0f5190a route full support 2026-10-26 18:51:56 +01:00
vcoppe c842da8dc1 reorder context menu items 2026-10-18 19:09:06 +02:00
vcoppe 5bc35f30d9 strip native menu 2026-10-18 18:43:12 +02:00
vcoppe 873e2e1654 Merge branch 'dev' into tauri 2026-10-18 18:15:37 +02:00
vcoppe 6c2d2879f4 use new endpoint for openhikingmap 2026-10-18 17:20:09 +02:00
vcoppe f5670cdd8e remove custom ign styles 2026-10-18 17:14:50 +02:00
vcoppe 741d35db1e sparse desktop build 2026-10-18 16:53:39 +02:00
vcoppe 6556c29492 fix 2026-10-18 16:38:59 +02:00
vcoppe 7fa850e4c8 fix routing control size 2026-10-18 16:34:03 +02:00
vcoppe f9e8d4c644 show popup once data is loaded 2026-10-18 16:27:51 +02:00
vcoppe a07da16163 sparser data transfers 2026-10-18 16:20:32 +02:00
vcoppe 4583874e7e sparser data transfers 2026-10-18 12:35:32 +02:00
vcoppe 48325e6016 performance improvements 2026-10-18 10:50:34 +02:00
vcoppe 9033401094 improved preview 2026-10-18 09:32:18 +02:00
vcoppe b64c192c4f consistent highlight color 2026-10-18 09:17:01 +02:00
vcoppe 9dd4af98a5 small fix 2026-10-18 09:09:00 +02:00
vcoppe 05510ece92 full screen 2026-10-18 00:38:53 +02:00
vcoppe 8bbcbdc097 remove focus-ring 2026-10-18 00:33:03 +02:00
vcoppe 3012f6bf73 fixes 2026-10-18 00:24:16 +02:00
vcoppe 5373d0f0d4 cleaning 2026-10-17 23:55:46 +02:00
vcoppe 8fd47d8b18 drop anywhere in folder 2026-10-17 23:39:44 +02:00
vcoppe a5c1f6ad33 fix folder styling 2026-10-17 23:35:56 +02:00
vcoppe 3ef2bf135a explorer actions 2026-10-17 23:23:29 +02:00
vcoppe adbf89e318 clarify filename 2026-10-17 22:17:17 +02:00
vcoppe 11709316d2 avoid selecting on resize 2026-10-17 20:02:09 +02:00
vcoppe d52b61f707 commit missing changes 2026-10-17 20:01:59 +02:00
vcoppe 358253b7a9 open links in browser 2026-10-17 19:57:50 +02:00
vcoppe 8d6885d58b remember language 2026-10-17 19:36:57 +02:00
vcoppe 128338aa05 fix sorting 2026-10-17 19:10:50 +02:00
vcoppe c84e1c9a06 fix routing on other file 2026-10-17 19:03:33 +02:00
vcoppe 9f49119eec no file delete on desktop 2026-10-17 18:57:40 +02:00
vcoppe 67a47cf065 fix tab size 2026-10-17 18:50:23 +02:00
vcoppe e57949dc01 fix 2026-10-17 18:33:50 +02:00
vcoppe da92fd15d2 fix sortable 2026-10-17 18:28:32 +02:00
vcoppe 01443943b3 fix sortable 2026-10-17 17:21:56 +02:00
vcoppe 970cf7d1e1 improved sticky folders 2026-10-17 16:39:41 +02:00
vcoppe 593b43e433 sticky folders 2026-10-17 16:24:19 +02:00
vcoppe 8fa88680b3 fix 2026-10-17 16:17:45 +02:00
vcoppe b2495d1466 progress 2026-10-17 16:14:17 +02:00
vcoppe ed90ba6a1f file explorer 2026-10-17 14:42:49 +02:00
vcoppe b0a3c3a9ba progress 2026-10-16 18:14:59 +02:00
vcoppe 005cc7b305 fix 2026-10-16 09:05:45 +02:00
vcoppe 6a0d434371 Merge branch 'dev' 2026-07-24 19:07:00 +02:00
vcoppe b319440a57 upgrade to maplibre v6 2026-07-24 19:06:39 +02:00
vcoppe b0d78b5f3c Merge branch 'dev' 2026-07-21 12:30:07 +02:00
vcoppe 2e8b2d6f30 add ukrainian 2026-07-21 11:20:31 +02:00
Dobrovolskyi Bohdan 5259d007c9 Fix Locale subscription (#369) 2026-07-21 12:04:42 +02:00
vcoppe cb36725a46 New Crowdin updates (#373)
* New translations en.json (German)

[ci skip]

* New translations view.mdx (German)

[ci skip]
2026-07-21 12:00:16 +02:00
vcoppe deef1bab47 fix embedding playground zoom 2026-07-21 11:11:13 +02:00
Dobrovolskyi Bohdan 108880f8cd Update language on map (#365) 2026-07-14 18:50:51 +02:00
vcoppe 37a659831b New Crowdin updates (#359)
* New translations en.json (Hungarian)

[ci skip]

* New translations en.json (Hungarian)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations file.mdx (Ukrainian)

[ci skip]

* New translations file.mdx (Ukrainian)

[ci skip]

* New translations settings.mdx (Ukrainian)

[ci skip]

* New translations view.mdx (Ukrainian)

[ci skip]

* New translations clean.mdx (Ukrainian)

[ci skip]

* New translations routing.mdx (Ukrainian)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations clean.mdx (Ukrainian)

[ci skip]

* New translations routing.mdx (Ukrainian)

[ci skip]

* New translations extract.mdx (Ukrainian)

[ci skip]

* New translations merge.mdx (Ukrainian)

[ci skip]

* New translations minify.mdx (Ukrainian)

[ci skip]

* New translations poi.mdx (Ukrainian)

[ci skip]

* New translations elevation.mdx (Ukrainian)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations routing.mdx (Ukrainian)

[ci skip]

* New translations scissors.mdx (Ukrainian)

[ci skip]

* New translations time.mdx (Ukrainian)

[ci skip]

* New translations faq.mdx (Ukrainian)

[ci skip]

* New translations en.json (Ukrainian)

[ci skip]

* New translations view.mdx (Ukrainian)

[ci skip]

* New translations faq.mdx (Ukrainian)

[ci skip]

* New translations files-and-stats.mdx (Ukrainian)

[ci skip]

* New translations getting-started.mdx (Ukrainian)

[ci skip]

* New translations gpx.mdx (Ukrainian)

[ci skip]

* New translations integration.mdx (Ukrainian)

[ci skip]

* New translations map-controls.mdx (Ukrainian)

[ci skip]

* New translations menu.mdx (Ukrainian)

[ci skip]

* New translations toolbar.mdx (Ukrainian)

[ci skip]

* New translations en.json (Basque)

[ci skip]
2026-07-14 18:49:25 +02:00
vcoppe fde6bafc10 Merge branch 'dev' 2026-06-18 23:07:43 +02:00
vcoppe 4537ea4730 Merge branch 'dev' 2026-06-03 19:05:25 +02:00
vcoppe 1954bbd764 Merge branch 'dev' 2026-06-03 18:48:43 +02:00
vcoppe 9d2da80893 Merge branch 'dev' 2026-05-31 15:26:17 +02:00
vcoppe 043d368c74 Merge branch 'dev' 2026-05-29 08:37:39 +02:00
vcoppe bb11d3375b Merge branch 'dev' 2026-05-28 19:29:34 +02:00
vcoppe bfe5961e27 Merge branch 'dev' 2026-05-21 20:27:41 +02:00
vcoppe 17559913fd Merge branch 'dev' 2026-04-19 16:49:41 +02:00
vcoppe 55590d68a6 Merge branch 'dev' 2026-04-19 16:34:17 +02:00
vcoppe 8e9f16c460 Merge branch 'dev' 2026-04-19 14:47:35 +02:00
vcoppe c60b64f24f Merge branch 'dev' 2026-04-17 22:10:40 +02:00
vcoppe fdb6fe4e52 Merge branch 'dev' 2026-04-17 20:11:25 +02:00
vcoppe 0bf168e67e Merge branch 'dev' 2026-04-09 21:10:56 +02:00
vcoppe d762a45eb9 Merge branch 'dev' 2026-04-09 20:57:30 +02:00
vcoppe 548ab9a459 Merge branch 'dev' 2026-04-07 22:19:15 +02:00
vcoppe 315c1f6a61 Merge branch 'dev' 2026-04-07 22:02:14 +02:00
vcoppe 98257bee12 Merge branch 'dev' 2026-04-06 18:26:22 +02:00
vcoppe 5561b7d9fe Merge branch 'dev' 2026-04-06 14:26:56 +02:00
vcoppe 320887206c Merge branch 'dev' 2026-04-04 23:15:01 +02:00
vcoppe 04a1bf6a55 Merge branch 'dev' 2026-04-03 08:47:57 +02:00
vcoppe 0989371874 Merge branch 'dev' 2026-04-03 08:06:19 +02:00
vcoppe 3dcd6e52d3 Merge branch 'dev' 2026-04-02 22:35:46 +02:00
vcoppe 0f06d0d461 update 2026-04-02 22:23:34 +02:00
vcoppe db33310a10 revert some changes 2026-04-02 22:20:47 +02:00
229 changed files with 8802 additions and 29678 deletions

No files matched your search

Generated
+78
View File
@@ -1430,7 +1430,9 @@ dependencies = [
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-opener",
"tauri-plugin-single-instance", "tauri-plugin-single-instance",
"trash",
"uuid", "uuid",
] ]
@@ -1870,6 +1872,25 @@ version = "2.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
@@ -2577,6 +2598,17 @@ version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "open"
version = "5.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc"
dependencies = [
"dunce",
"is-wsl",
"libc",
]
[[package]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
@@ -3818,6 +3850,28 @@ dependencies = [
"url", "url",
] ]
[[package]]
name = "tauri-plugin-opener"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53810be1b4fed22bb6ac3f4cab5a2b80d9391d1af436bda145607532bc0bb219"
dependencies = [
"dunce",
"glob",
"objc2-app-kit",
"objc2-foundation",
"open",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.21",
"url",
"windows",
"zbus",
]
[[package]] [[package]]
name = "tauri-plugin-single-instance" name = "tauri-plugin-single-instance"
version = "2.5.3" version = "2.5.3"
@@ -4241,6 +4295,24 @@ dependencies = [
"once_cell", "once_cell",
] ]
[[package]]
name = "trash"
version = "5.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be89b3fe156965d29ac4f8522f3a640c655affdd9f21cb4f36857f0c92c00317"
dependencies = [
"chrono",
"libc",
"log",
"objc2",
"objc2-foundation",
"once_cell",
"percent-encoding",
"scopeguard",
"urlencoding",
"windows",
]
[[package]] [[package]]
name = "tray-icon" name = "tray-icon"
version = "0.25.1" version = "0.25.1"
@@ -4317,6 +4389,12 @@ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]] [[package]]
name = "urlpattern" name = "urlpattern"
version = "0.6.0" version = "0.6.0"
+51 -8
View File
@@ -49,7 +49,7 @@ pub fn select_elements<H: History>(
} }
/// Selects all the elements of the same kind as the selected ones, in the same place: all the /// Selects all the elements of the same kind as the selected ones, in the same place: all the
/// files, the tracks of the file, the segments of the track, the waypoints of the file. All the /// files, the routes or the tracks of the file, the segments of the track, the waypoints of the file. All the
/// files when nothing is selected. /// files when nothing is selected.
pub fn select_all<H: History>(engine: &mut Engine<H>) -> Outcome { pub fn select_all<H: History>(engine: &mut Engine<H>) -> Outcome {
execute(engine, Action::SelectAll) execute(engine, Action::SelectAll)
@@ -62,6 +62,19 @@ pub fn arrow_select<H: History>(engine: &mut Engine<H>, down: bool, add: bool) -
execute(engine, Action::ArrowSelect { down, add }) execute(engine, Action::ArrowSelect { down, add })
} }
pub fn select_routes<H: History>(
engine: &mut Engine<H>,
file_id: Uuid,
route_ids: &[Uuid],
mode: engine::SelectMode,
) -> Outcome {
let selection = engine::Selection::Route {
file_id: engine::FileId(file_id),
rte_ids: route_ids.iter().copied().map(engine::RouteId).collect(),
};
select_elements(engine, selection, mode)
}
pub fn select_tracks<H: History>( pub fn select_tracks<H: History>(
engine: &mut Engine<H>, engine: &mut Engine<H>,
file_id: Uuid, file_id: Uuid,
@@ -135,21 +148,30 @@ pub fn redo<H: History>(engine: &mut Engine<H>) -> Outcome {
// Files // Files
/// Creates a file and selects it. With `lng` and `lat`, it starts with a track and a segment that /// Creates a file and selects it. With `route`, it starts with a route. With `lng` and `lat`, it
/// hold that trackpoint (at `ele`, 0 by default). /// starts with a segment that holds that trackpoint (at `ele`, 0 by default): the one of its route
/// with `route`, of a track otherwise.
pub fn new_file<H: History>( pub fn new_file<H: History>(
engine: &mut Engine<H>, engine: &mut Engine<H>,
name: &str, name: &str,
lng: Option<f64>, lng: Option<f64>,
lat: Option<f64>, lat: Option<f64>,
ele: Option<f64>, ele: Option<f64>,
route: bool,
) -> Outcome { ) -> Outcome {
let trackpoint = lng.zip(lat).map(|(lng, lat)| engine::NewTrackpoint { let trackpoint = lng.zip(lat).map(|(lng, lat)| engine::NewTrackpoint {
lng, lng,
lat, lat,
ele: ele.unwrap_or_default(), ele: ele.unwrap_or_default(),
}); });
edit(engine, Command::New(engine::New { name, trackpoint })) edit(
engine,
Command::New(engine::New {
name,
trackpoint,
route,
}),
)
} }
/// Loads files as a single command (one undo step). The files are in `data`, one after the other, /// Loads files as a single command (one undo step). The files are in `data`, one after the other,
@@ -183,8 +205,8 @@ pub fn load_files<H: History>(
} }
/// Brings changes of the files from the outside in (see [`engine::SyncFiles`]): each file is /// Brings changes of the files from the outside in (see [`engine::SyncFiles`]): each file is
/// `(id, data, name)`, and replaces the file of that id, or is added if there is none. The files to /// `(id, data, name)`, and replaces the file of that id, or is added if there is none; it is called
/// `remove` are deleted. The selection is left alone. /// `name`, whatever its data says. The files to `remove` are deleted. The selection is left alone.
pub fn sync_files<H: History>( pub fn sync_files<H: History>(
engine: &mut Engine<H>, engine: &mut Engine<H>,
files: &[(Uuid, &[u8], &str)], files: &[(Uuid, &[u8], &str)],
@@ -232,6 +254,18 @@ pub fn reorder<H: History>(engine: &mut Engine<H>, file_ids_: &[Uuid], index: us
) )
} }
/// Names a file in all its states, as if it had always been called so (not undoable): for a file
/// that is written to a file of another name, whose name it takes.
pub fn rename_file<H: History>(engine: &mut Engine<H>, file_id: Uuid, name: &str) -> Outcome {
execute(
engine,
Action::Rename {
file_id: engine::FileId(file_id),
name: name.to_owned(),
},
)
}
/// Puts the selected elements in the clipboard. Nothing happens when nothing is selected. /// Puts the selected elements in the clipboard. Nothing happens when nothing is selected.
pub fn copy<H: History>(engine: &mut Engine<H>) -> Outcome { pub fn copy<H: History>(engine: &mut Engine<H>) -> Outcome {
execute(engine, Action::Copy) execute(engine, Action::Copy)
@@ -292,6 +326,15 @@ pub fn style<H: History>(
) )
} }
/// Converts the selected routes to tracks, or the selected tracks or segments to routes.
pub fn convert<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(engine, Command::Convert(engine::Convert))
}
pub fn new_route<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(engine, Command::NewRoute(engine::NewRoute))
}
pub fn new_track<H: History>(engine: &mut Engine<H>) -> Outcome { pub fn new_track<H: History>(engine: &mut Engine<H>) -> Outcome {
edit(engine, Command::NewTrack(engine::NewTrack)) edit(engine, Command::NewTrack(engine::NewTrack))
} }
@@ -563,7 +606,7 @@ pub fn route<H: History>(
at_revision(engine, revision, |engine| { at_revision(engine, revision, |engine| {
edit( edit(
engine, engine,
Command::Route(engine::Route { Command::Reroute(engine::Reroute {
start, start,
end, end,
lng, lng,
@@ -606,7 +649,7 @@ pub fn change_loop_start<H: History>(engine: &mut Engine<H>, revision: u32, inde
}) })
} }
/// Cuts the file, the track or the segment (see `SplitType`) of the trackpoint `at` of the /// Cuts the file, the route, the track or the segment (see `SplitType`) of the trackpoint `at` of the
/// selection in two, there: the trackpoint ends the first part and starts the second one. A file that /// selection in two, there: the trackpoint ends the first part and starts the second one. A file that
/// is cut is kept (and its two parts are new files) if the history needs it, see /// is cut is kept (and its two parts are new files) if the history needs it, see
/// [`History::KEEPS_ORIGINALS`]. /// [`History::KEEPS_ORIGINALS`].
+105 -16
View File
@@ -68,6 +68,11 @@ impl Outcome {
Self::new(&engine::Outcome::default(), None) Self::new(&engine::Outcome::default(), None)
} }
/// The ids of the files that the call added.
pub fn added(&self) -> &[String] {
&self.added
}
/// Whether the call did nothing and says why. /// Whether the call did nothing and says why.
pub fn has_error(&self) -> bool { pub fn has_error(&self) -> bool {
self.error.is_some() self.error.is_some()
@@ -126,6 +131,10 @@ pub enum Selection {
File { File {
file_ids: Vec<Uuid>, file_ids: Vec<Uuid>,
}, },
Route {
file_id: Uuid,
route_ids: Vec<Uuid>,
},
Track { Track {
file_id: Uuid, file_id: Uuid,
track_ids: Vec<Uuid>, track_ids: Vec<Uuid>,
@@ -152,6 +161,10 @@ impl From<&engine::Selection> for Selection {
S::File { file_ids } => Self::File { S::File { file_ids } => Self::File {
file_ids: file_ids.iter().map(|id| id.0).collect(), file_ids: file_ids.iter().map(|id| id.0).collect(),
}, },
S::Route { file_id, rte_ids } => Self::Route {
file_id: file_id.0,
route_ids: rte_ids.iter().map(|id| id.0).collect(),
},
S::Track { file_id, trk_ids } => Self::Track { S::Track { file_id, trk_ids } => Self::Track {
file_id: file_id.0, file_id: file_id.0,
track_ids: trk_ids.iter().map(|id| id.0).collect(), track_ids: trk_ids.iter().map(|id| id.0).collect(),
@@ -182,6 +195,10 @@ impl From<Selection> for engine::Selection {
Selection::File { file_ids } => S::File { Selection::File { file_ids } => S::File {
file_ids: file_ids.into_iter().map(FileId).collect(), file_ids: file_ids.into_iter().map(FileId).collect(),
}, },
Selection::Route { file_id, route_ids } => S::Route {
file_id: FileId(file_id),
rte_ids: route_ids.into_iter().map(engine::RouteId).collect(),
},
Selection::Track { file_id, track_ids } => S::Track { Selection::Track { file_id, track_ids } => S::Track {
file_id: FileId(file_id), file_id: FileId(file_id),
trk_ids: track_ids.into_iter().map(engine::TrackId).collect(), trk_ids: track_ids.into_iter().map(engine::TrackId).collect(),
@@ -224,6 +241,10 @@ pub enum MoveTarget {
Files { Files {
index: f64, index: f64,
}, },
Routes {
file_id: Uuid,
index: f64,
},
Tracks { Tracks {
file_id: Uuid, file_id: Uuid,
index: f64, index: f64,
@@ -245,6 +266,10 @@ impl From<MoveTarget> for engine::MoveTarget {
let index = |index: f64| index.max(0.0) as usize; let index = |index: f64| index.max(0.0) as usize;
match target { match target {
MoveTarget::Files { index: i } => T::Files { index: index(i) }, MoveTarget::Files { index: i } => T::Files { index: index(i) },
MoveTarget::Routes { file_id, index: i } => T::Routes {
file_id: FileId(file_id),
index: index(i),
},
MoveTarget::Tracks { file_id, index: i } => T::Tracks { MoveTarget::Tracks { file_id, index: i } => T::Tracks {
file_id: FileId(file_id), file_id: FileId(file_id),
index: index(i), index: index(i),
@@ -281,6 +306,9 @@ impl Clipboard {
engine::ClipboardIds::Files(ids) => { engine::ClipboardIds::Files(ids) => {
("files", strings(ids.into_iter().map(|id| id.0).collect())) ("files", strings(ids.into_iter().map(|id| id.0).collect()))
} }
engine::ClipboardIds::Routes(ids) => {
("routes", strings(ids.into_iter().map(|id| id.0).collect()))
}
engine::ClipboardIds::Tracks(ids) => { engine::ClipboardIds::Tracks(ids) => {
("tracks", strings(ids.into_iter().map(|id| id.0).collect())) ("tracks", strings(ids.into_iter().map(|id| id.0).collect()))
} }
@@ -310,14 +338,15 @@ pub struct FileStructure {
name: String, name: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
desc: Option<String>, desc: Option<String>,
routes: Vec<RouteNode>,
tracks: Vec<TrackNode>, tracks: Vec<TrackNode>,
waypoints: Vec<WaypointNode>, waypoints: Vec<WaypointNode>,
waypoints_rev: String, waypoints_rev: String,
} }
/// The name, description and style of a route or a track.
#[derive(Serialize)] #[derive(Serialize)]
struct TrackNode { struct PathInfo {
id: String,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>, name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -328,6 +357,33 @@ struct TrackNode {
opacity: Option<f64>, opacity: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
width: Option<f64>, width: Option<f64>,
}
impl From<&engine::PathInfo> for PathInfo {
fn from(info: &engine::PathInfo) -> Self {
Self {
name: info.name.clone(),
desc: info.desc.clone(),
color: info.color.clone(),
opacity: info.opacity,
width: info.width,
}
}
}
#[derive(Serialize)]
struct RouteNode {
id: String,
#[serde(flatten)]
info: PathInfo,
segment: SegmentNode,
}
#[derive(Serialize)]
struct TrackNode {
id: String,
#[serde(flatten)]
info: PathInfo,
segments: Vec<SegmentNode>, segments: Vec<SegmentNode>,
} }
@@ -338,6 +394,16 @@ struct SegmentNode {
length: f64, length: f64,
} }
impl From<&engine::SegmentNode> for SegmentNode {
fn from(seg: &engine::SegmentNode) -> Self {
Self {
id: seg.id.0.to_string(),
rev: seg.rev_id.0.to_string(),
length: seg.len as f64,
}
}
}
#[derive(Serialize)] #[derive(Serialize)]
struct WaypointNode { struct WaypointNode {
id: String, id: String,
@@ -353,25 +419,22 @@ impl FileStructure {
id: file.id.0.to_string(), id: file.id.0.to_string(),
name: file.name.clone(), name: file.name.clone(),
desc: file.desc.clone(), desc: file.desc.clone(),
routes: file
.routes
.iter()
.map(|rte| RouteNode {
id: rte.id.0.to_string(),
info: (&rte.info).into(),
segment: (&rte.segment).into(),
})
.collect(),
tracks: file tracks: file
.tracks .tracks
.iter() .iter()
.map(|trk| TrackNode { .map(|trk| TrackNode {
id: trk.id.0.to_string(), id: trk.id.0.to_string(),
name: trk.name.clone(), info: (&trk.info).into(),
desc: trk.desc.clone(), segments: trk.segments.iter().map(Into::into).collect(),
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(), .collect(),
waypoints: file waypoints: file
@@ -517,6 +580,32 @@ impl TrackpointDetails {
} }
} }
/// A trackpoint of the selection: what the statistics buffers do not hold for each trackpoint.
/// Absent fields are unknown.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SelectionPoint {
/// Slope (%) at the trackpoint.
#[serde(skip_serializing_if = "Option::is_none")]
pub slope: Option<f64>,
/// ms since the epoch.
#[serde(skip_serializing_if = "Option::is_none")]
pub time: Option<f64>,
/// Slope (%) and length (km) of the smoothed elevation segment of the trackpoint.
#[serde(skip_serializing_if = "Option::is_none")]
pub slope_segment_slope: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub slope_segment_distance: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub surface: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub highway: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sac_scale: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mtb_scale: Option<String>,
}
// Routing // Routing
/// A category of the trackpoints of a route, as in `RouteCategory` of the engine. /// A category of the trackpoints of a route, as in `RouteCategory` of the engine.
+44 -9
View File
@@ -76,9 +76,7 @@ pub fn trackpoint<H: History>(
} }
/// A file as GPX (UTF-8 bytes), `None` if it does not exist. The options say which data of the /// A file as GPX (UTF-8 bytes), `None` if it does not exist. The options say which data of the
/// trackpoints to keep, and with `as_route` that the segments are written as routes (`rte`) /// trackpoints to keep.
/// instead of tracks: the anchors are the route points, and the trackpoints between them the
/// points of their path.
pub fn export_file<H: History>( pub fn export_file<H: History>(
engine: &Engine<H>, engine: &Engine<H>,
file_id: Uuid, file_id: Uuid,
@@ -153,9 +151,6 @@ buffers!(
total_distance, total_distance,
elevation_gain, elevation_gain,
elevation_loss, elevation_loss,
slope,
slope_segment_slope,
slope_segment_distance,
lng, lng,
lat, lat,
ele ele
@@ -175,9 +170,49 @@ macro_rules! optional_buffers {
optional_buffers!(moving_distance: f64, speed: f64, hr: f64, cad: f64, atemp: f64, power: f64); optional_buffers!(moving_distance: f64, speed: f64, hr: f64, cad: f64, atemp: f64, power: f64);
optional_buffers!(total_time: i64, moving_time: i64); optional_buffers!(total_time: i64, moving_time: i64);
/// Timestamps in milliseconds since the epoch (`NO_TIME` when missing), see `optional_buffers`. // The smoothed elevation segments of the selection, over which the slope is averaged, as
pub fn timestamps<H: History>(engine: &Engine<H>) -> Option<&[i64]> { // intervals of trackpoints: `slope_segment_starts` holds the index of the first trackpoint of each
engine.statistics().time.as_deref() // segment, and `slope_segment_slopes` its slope (%). Their length is in `selection_point`.
pub fn slope_segment_starts<H: History>(engine: &Engine<H>) -> &[u32] {
&engine.statistics().slope_segments.starts
}
pub fn slope_segment_slopes<H: History>(engine: &Engine<H>) -> &[f64] {
&engine.statistics().slope_segments.slopes
}
/// What the buffers do not hold for each trackpoint of the selection, for the one at `index`
/// (numbered as in the buffers), `None` if there is none: for example the one that is hovered on
/// the elevation profile.
pub fn selection_point<H: History>(engine: &Engine<H>, index: usize) -> Option<SelectionPoint> {
let stats = engine.statistics();
if index >= stats.len() {
return None;
}
let categories = engine.categories();
let name = |intervals: &engine::Intervals, categories: &engine::Categories| {
let value = intervals.value_at(index);
let code = value.checked_sub(1)?;
categories.name(code).map(str::to_owned)
};
let segment = stats.slope_segments.at(index);
let finite = |value: f64| value.is_finite().then_some(value);
Some(SelectionPoint {
slope: finite(stats.slope[index]),
time: stats
.time
.as_ref()
.map(|time| time[index])
.filter(|time| *time != engine::NO_TIME)
.map(|time| time as f64),
slope_segment_slope: segment.and_then(|i| finite(stats.slope_segments.slopes[i])),
slope_segment_distance: segment.and_then(|i| finite(stats.slope_segments.distances[i])),
surface: name(&stats.surface, &categories.surface),
highway: name(&stats.highway, &categories.highway),
sac_scale: name(&stats.sac_scale, &categories.sac_scale),
mtb_scale: name(&stats.mtb_scale, &categories.mtb_scale),
})
} }
// Surface, highway, SAC scale and MTB scale of the trackpoints, as intervals of trackpoints that // Surface, highway, SAC scale and MTB scale of the trackpoints, as intervals of trackpoints that
+14 -1
View File
@@ -6,7 +6,7 @@
//! between them are kept: every part comes after the previous one in the new order, as late as the //! between them are kept: every part comes after the previous one in the new order, as late as the
//! original one came after the part that followed it. //! original one came after the part that followed it.
use crate::{Track, TrackSegment, Trackpoint}; use crate::{Route, Track, TrackSegment, Trackpoint};
/// The time of the first trackpoint of a segment. /// The time of the first trackpoint of a segment.
pub fn start_time(segment: &TrackSegment) -> Option<i64> { pub fn start_time(segment: &TrackSegment) -> Option<i64> {
@@ -94,6 +94,19 @@ pub fn reverse_segments(
} }
} }
/// Reverses the routes of a file, and their trackpoints, like the segments of a track (see
/// [`reverse_segments`]).
pub fn reverse_routes(routes: &mut [Route], original_next: Option<i64>, new_previous: Option<i64>) {
routes.reverse();
let (mut next, mut previous) = (original_next, new_previous);
for route in routes.iter_mut() {
let original_start = start_time(&route.seg);
reverse_segment(&mut route.seg, next, previous);
next = original_start;
previous = end_time(&route.seg);
}
}
/// Reverses the tracks of a file, and their segments and trackpoints. /// Reverses the tracks of a file, and their segments and trackpoints.
pub fn reverse_tracks(tracks: &mut [Track], original_next: Option<i64>, new_previous: Option<i64>) { pub fn reverse_tracks(tracks: &mut [Track], original_next: Option<i64>, new_previous: Option<i64>) {
tracks.reverse(); tracks.reverse();
+51 -1
View File
@@ -1,4 +1,4 @@
use crate::{Link, Track, Waypoints}; use crate::{Link, Route, Track, TrackSegment, Waypoints};
use super::common::uuid_id; use super::common::uuid_id;
@@ -8,6 +8,9 @@ uuid_id!(FileId);
pub struct File { pub struct File {
pub id: FileId, pub id: FileId,
pub info: FileInfo, pub info: FileInfo,
/// The routes come before the tracks, as in GPX: everything that goes through the segments
/// of a file (statistics, the elevation profile...) takes the ones of the routes first.
pub rte: Vec<Route>,
pub trk: Vec<Track>, pub trk: Vec<Track>,
pub wpt: Waypoints, pub wpt: Waypoints,
} }
@@ -29,3 +32,50 @@ pub struct Author {
/// The author has a single link, unlike the other elements. /// The author has a single link, unlike the other elements.
pub link: Option<Link>, pub link: Option<Link>,
} }
/// Where a segment is in its file: the one of a route, or one of a track.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SegmentIndex {
Route(usize),
Track { trk: usize, seg: usize },
}
impl File {
/// The segments of the routes, then the ones of the tracks.
pub fn segments(&self) -> impl Iterator<Item = &TrackSegment> {
self.rte
.iter()
.map(|rte| &rte.seg)
.chain(self.trk.iter().flat_map(|trk| &trk.trkseg))
}
/// Where the segments are, in the order of [`File::segments`].
pub fn segment_indices(&self) -> impl Iterator<Item = SegmentIndex> + '_ {
(0..self.rte.len())
.map(SegmentIndex::Route)
.chain(self.trk.iter().enumerate().flat_map(|(trk, track)| {
(0..track.trkseg.len()).map(move |seg| SegmentIndex::Track { trk, seg })
}))
}
/// The segment at `index`. Panics if there is none.
pub fn segment(&self, index: SegmentIndex) -> &TrackSegment {
match index {
SegmentIndex::Route(rte) => &self.rte[rte].seg,
SegmentIndex::Track { trk, seg } => &self.trk[trk].trkseg[seg],
}
}
/// The segment at `index`. Panics if there is none.
pub fn segment_mut(&mut self, index: SegmentIndex) -> &mut TrackSegment {
match index {
SegmentIndex::Route(rte) => &mut self.rte[rte].seg,
SegmentIndex::Track { trk, seg } => &mut self.trk[trk].trkseg[seg],
}
}
/// Whether the file has no route nor track.
pub fn has_no_path(&self) -> bool {
self.rte.is_empty() && self.trk.is_empty()
}
}
+2
View File
@@ -3,6 +3,7 @@ mod chunk;
mod chunked; mod chunked;
mod common; mod common;
mod file; mod file;
mod route;
mod segment; mod segment;
mod track; mod track;
mod trackpoint; mod trackpoint;
@@ -14,6 +15,7 @@ pub use chunk::*;
pub use chunked::*; pub use chunked::*;
pub use common::*; pub use common::*;
pub use file::*; pub use file::*;
pub use route::*;
pub use segment::*; pub use segment::*;
pub use track::*; pub use track::*;
pub use trackpoint::*; pub use trackpoint::*;
+15
View File
@@ -0,0 +1,15 @@
use crate::{TrackInfo, TrackSegment};
use super::common::uuid_id;
uuid_id!(RouteId);
/// A route of the file (`rte`): like a track, but with a single segment, its points.
///
/// It has the information of a track: GPX gives the same to both.
#[derive(Debug, Default, Clone)]
pub struct Route {
pub id: RouteId,
pub info: TrackInfo,
pub seg: TrackSegment,
}
+43 -44
View File
@@ -1,6 +1,6 @@
use crate::{ use crate::{
Author, Chunk, File, Link, LngLat, Track, TrackSegment, Trackpoint, TrackpointCategories, Author, Chunk, File, Link, LngLat, Route, Track, TrackSegment, Trackpoint,
TrackpointChunk, Waypoint, WaypointChunk, distance, TrackpointCategories, TrackpointChunk, Waypoint, WaypointChunk, distance,
}; };
use chrono::DateTime; use chrono::DateTime;
use quick_xml::Error; use quick_xml::Error;
@@ -18,6 +18,7 @@ enum GPXElement {
Author(Author), Author(Author),
Link(Link), Link(Link),
Text, Text,
/// A track, or a route, whose information is read the same way.
Track(Track), Track(Track),
Segment(TrackSegment), Segment(TrackSegment),
Trackpoint(Trackpoint), Trackpoint(Trackpoint),
@@ -396,7 +397,7 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
let mut stack: Vec<GPXElement> = vec![]; let mut stack: Vec<GPXElement> = vec![];
let mut trkpt_chunk = TrackpointChunk::default(); let mut trkpt_chunk = TrackpointChunk::default();
let mut wpt_chunk = WaypointChunk::default(); let mut wpt_chunk = WaypointChunk::default();
// Routes are read as tracks of a single segment, see `RouteReader`. The points of the detailed // The segment of a route is read by `RouteReader`. The points of the detailed
// path that a route point comes with (`gpxx:rpt`) are collected until the route point is read. // path that a route point comes with (`gpxx:rpt`) are collected until the route point is read.
let mut route = RouteReader::default(); let mut route = RouteReader::default();
let mut rpt_points: Vec<Trackpoint> = vec![]; let mut rpt_points: Vec<Trackpoint> = vec![];
@@ -530,12 +531,12 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
} }
} }
"rte" => { "rte" => {
if let Some(GPXElement::Track(mut trk)) = stack.pop() { if let Some(GPXElement::Track(trk)) = stack.pop() {
let trkseg = std::mem::take(&mut route).finish(); gpx.rte.push(Route {
if !trkseg.is_empty() { info: trk.info,
trk.trkseg.push(trkseg); seg: std::mem::take(&mut route).finish(),
} ..Default::default()
gpx.trk.push(trk); });
} }
} }
"rtept" => { "rtept" => {
@@ -740,38 +741,37 @@ mod tests {
} }
#[test] #[test]
fn test_parse_routes_as_tracks() { fn test_parse_routes() {
let gpx = parse_data("with_routes"); let gpx = parse_data("with_routes");
assert_eq!(gpx.trk.len(), 2); assert!(gpx.trk.is_empty());
let trk = &gpx.trk[0]; assert_eq!(gpx.rte.len(), 2);
assert_eq!(trk.info.name.as_deref(), Some("route 1")); let rte = &gpx.rte[0];
assert_eq!(trk.info.type_.as_deref(), Some("Cycling")); assert_eq!(rte.info.name.as_deref(), Some("route 1"));
assert_eq!(trk.trkseg.len(), 1); assert_eq!(rte.info.type_.as_deref(), Some("Cycling"));
let trkseg = &trk.trkseg[0]; assert_eq!(rte.seg.len(), 49);
assert_eq!(trkseg.len(), 49); let trkpt = &rte.seg[0];
let trkpt = &trkseg[0];
assert_eq!(trkpt.coordinates.lat, 50.790867); assert_eq!(trkpt.coordinates.lat, 50.790867);
assert_eq!(trkpt.coordinates.lng, 4.404968); assert_eq!(trkpt.coordinates.lng, 4.404968);
assert_eq!(trkpt.ele, 109.0); assert_eq!(trkpt.ele, 109.0);
let trk = &gpx.trk[1]; let rte = &gpx.rte[1];
assert_eq!(trk.info.name.as_deref(), Some("route 2")); assert_eq!(rte.info.name.as_deref(), Some("route 2"));
assert_eq!(trk.trkseg[0].len(), 28); assert_eq!(rte.seg.len(), 28);
assert_ne!(gpx.rte[0].id, rte.id);
} }
#[test] #[test]
fn test_parse_route_point_extensions() { fn test_parse_route_point_extensions() {
let gpx = parse_data("with_route_extensions"); let gpx = parse_data("with_route_extensions");
// a route without points has no segment // a route without points has an empty segment
assert_eq!(gpx.trk.len(), 2); assert_eq!(gpx.rte.len(), 2);
assert!(gpx.trk[1].trkseg.is_empty()); assert!(gpx.rte[1].seg.is_empty());
let trk = &gpx.trk[0]; let rte = &gpx.rte[0];
assert_eq!(trk.info.name.as_deref(), Some("with extensions")); assert_eq!(rte.info.name.as_deref(), Some("with extensions"));
assert_eq!(trk.trkseg.len(), 1); let points: Vec<_> = rte.seg.iter().collect();
let points: Vec<_> = trk.trkseg[0].iter().collect();
// the route points are kept, the points of the detailed path follow the one they come with // the route points are kept, the points of the detailed path follow the one they come with
let coordinates: Vec<_> = points let coordinates: Vec<_> = points
.iter() .iter()
@@ -788,14 +788,13 @@ mod tests {
#[test] #[test]
fn test_route_points_are_the_anchors_and_nothing_else_is() { fn test_route_points_are_the_anchors_and_nothing_else_is() {
let gpx = parse_data("with_route_extensions"); let gpx = parse_data("with_route_extensions");
let anchors: Vec<_> = gpx.trk[0].trkseg[0].iter().map(|p| p.anchor).collect(); let anchors: Vec<_> = gpx.rte[0].seg.iter().map(|p| p.anchor).collect();
assert_eq!(anchors, [Some(0), None, None, Some(0), Some(0)]); assert_eq!(anchors, [Some(0), None, None, Some(0), Some(0)]);
// no anchor is computed for the points of a route that has none in between // no anchor is computed for the points of a route that has none in between
let gpx = parse_data("with_routes"); let gpx = parse_data("with_routes");
for trk in &gpx.trk { for rte in &gpx.rte {
let segment = &trk.trkseg[0]; assert!(rte.seg.iter().all(|p| p.anchor == Some(0)));
assert!(segment.iter().all(|p| p.anchor == Some(0)));
} }
// while a track gets the anchors that its shape needs // while a track gets the anchors that its shape needs
let track = parse_data("with_tracks"); let track = parse_data("with_tracks");
@@ -807,14 +806,14 @@ mod tests {
#[test] #[test]
fn test_parse_the_points_of_the_detailed_path_of_a_route() { fn test_parse_the_points_of_the_detailed_path_of_a_route() {
let gpx = parse_data("with_route_points"); let gpx = parse_data("with_route_points");
assert_eq!(gpx.trk.len(), 1); assert_eq!(gpx.rte.len(), 1);
let trk = &gpx.trk[0]; let rte = &gpx.rte[0];
assert_eq!(trk.info.name.as_deref(), Some("Lausanne")); assert_eq!(rte.info.name.as_deref(), Some("Lausanne"));
assert_eq!(trk.info.cmt.as_deref(), Some("comment")); assert_eq!(rte.info.cmt.as_deref(), Some("comment"));
assert_eq!(trk.info.desc.as_deref(), Some("description")); assert_eq!(rte.info.desc.as_deref(), Some("description"));
assert_eq!(trk.info.type_.as_deref(), Some("Cycling")); assert_eq!(rte.info.type_.as_deref(), Some("Cycling"));
let points: Vec<_> = trk.trkseg[0].iter().cloned().collect(); let points: Vec<_> = rte.seg.iter().cloned().collect();
// 3 route points and 4 points between them, the repeated one being kept // 3 route points and 4 points between them, the repeated one being kept
assert_eq!(points.len(), 7); assert_eq!(points.len(), 7);
let kinds: Vec<_> = points.iter().map(|p| p.anchor.is_some()).collect(); let kinds: Vec<_> = points.iter().map(|p| p.anchor.is_some()).collect();
@@ -846,7 +845,7 @@ mod tests {
</rtept> </rtept>
</rte></gpx>"#; </rte></gpx>"#;
let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap(); let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap();
let points: Vec<_> = gpx.trk[0].trkseg[0].iter().cloned().collect(); let points: Vec<_> = gpx.rte[0].seg.iter().cloned().collect();
let coordinates: Vec<_> = points.iter().map(|p| p.coordinates.lat).collect(); let coordinates: Vec<_> = points.iter().map(|p| p.coordinates.lat).collect();
assert_eq!(coordinates, [1.0, 2.0, 2.1, 2.2]); assert_eq!(coordinates, [1.0, 2.0, 2.1, 2.2]);
// the route point is an anchor, and so is the end of the path // the route point is an anchor, and so is the end of the path
@@ -861,7 +860,7 @@ mod tests {
<gpxx:RoutePointExtension><gpxx:rpt lat="1.5" lon="1.5"/></gpxx:RoutePointExtension> <gpxx:RoutePointExtension><gpxx:rpt lat="1.5" lon="1.5"/></gpxx:RoutePointExtension>
</extensions></rtept></rte></gpx>"#; </extensions></rtept></rte></gpx>"#;
let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap(); let gpx = parse(data.as_bytes(), &mut Default::default()).unwrap();
let anchors: Vec<_> = gpx.trk[0].trkseg[0].iter().map(|p| p.anchor).collect(); let anchors: Vec<_> = gpx.rte[0].seg.iter().map(|p| p.anchor).collect();
assert_eq!(anchors, [Some(0), Some(0)]); assert_eq!(anchors, [Some(0), Some(0)]);
} }
@@ -930,14 +929,14 @@ mod tests {
assert_eq!(waypoints[0].links[2].text.as_deref(), Some("three")); assert_eq!(waypoints[0].links[2].text.as_deref(), Some("three"));
assert!(waypoints[1].links.is_empty()); assert!(waypoints[1].links.is_empty());
// tracks, and routes which are read as tracks // tracks, and routes
assert_eq!(gpx.trk.len(), 2); assert_eq!((gpx.trk.len(), gpx.rte.len()), (1, 1));
assert_eq!( assert_eq!(
hrefs(&gpx.trk[0].info.links), hrefs(&gpx.trk[0].info.links),
["https://example.com/trk-1", "https://example.com/trk-2"] ["https://example.com/trk-1", "https://example.com/trk-2"]
); );
assert_eq!( assert_eq!(
hrefs(&gpx.trk[1].info.links), hrefs(&gpx.rte[0].info.links),
["https://example.com/rte-1", "https://example.com/rte-2"] ["https://example.com/rte-1", "https://example.com/rte-2"]
); );
} }
+140 -115
View File
@@ -8,9 +8,6 @@ use crate::{File, Link, Track, Trackpoint, TrackpointCategories, Waypoint};
/// What the written file contains: the data of the trackpoints that are `false` are left out. /// What the written file contains: the data of the trackpoints that are `false` are left out.
/// The same type tells which data a file has, see [`File::exportable_data`]. /// The same type tells which data a file has, see [`File::exportable_data`].
///
/// With `as_route`, the file is written with routes instead of tracks, see [`write`]. It is not
/// data: it is off in `ALL` and `NONE`, and in what [`File::exportable_data`] gives.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ExportOptions { pub struct ExportOptions {
pub time: bool, pub time: bool,
@@ -20,8 +17,6 @@ pub struct ExportOptions {
pub power: bool, pub power: bool,
/// The OpenStreetMap data: surface, highway, SAC scale and MTB scale. /// The OpenStreetMap data: surface, highway, SAC scale and MTB scale.
pub osm: bool, pub osm: bool,
/// Writes every segment as a route (`rte`) instead of a track.
pub as_route: bool,
} }
impl ExportOptions { impl ExportOptions {
@@ -36,7 +31,6 @@ impl ExportOptions {
atemp: value, atemp: value,
power: value, power: value,
osm: value, osm: value,
as_route: false,
} }
} }
@@ -49,7 +43,6 @@ impl ExportOptions {
atemp: self.atemp || other.atemp, atemp: self.atemp || other.atemp,
power: self.power || other.power, power: self.power || other.power,
osm: self.osm || other.osm, osm: self.osm || other.osm,
as_route: self.as_route || other.as_route,
} }
} }
} }
@@ -59,12 +52,7 @@ impl File {
/// options of [`write`] can leave out. /// options of [`write`] can leave out.
pub fn exportable_data(&self) -> ExportOptions { pub fn exportable_data(&self) -> ExportOptions {
let mut data = ExportOptions::NONE; let mut data = ExportOptions::NONE;
for point in self for point in self.segments().flat_map(|seg| seg.iter()) {
.trk
.iter()
.flat_map(|trk| &trk.trkseg)
.flat_map(|seg| seg.iter())
{
data.time |= point.time.is_some(); data.time |= point.time.is_some();
data.hr |= point.hr.is_some(); data.hr |= point.hr.is_some();
data.cad |= point.cad.is_some(); data.cad |= point.cad.is_some();
@@ -82,6 +70,10 @@ impl File {
} }
} }
/// The highest zoom level from which an anchor is shown that makes it a route point, see [`write`]:
/// the anchors that only appear when zooming in further are details of the path.
pub const ROUTE_POINT_MAX_ZOOM: u8 = 12;
const SCHEMA_LOCATION: &str = "http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.garmin.com/xmlschemas/PowerExtension/v1 http://www.garmin.com/xmlschemas/PowerExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd"; const SCHEMA_LOCATION: &str = "http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd http://www.garmin.com/xmlschemas/GpxExtensions/v3 http://www.garmin.com/xmlschemas/GpxExtensionsv3.xsd http://www.garmin.com/xmlschemas/TrackPointExtension/v1 http://www.garmin.com/xmlschemas/TrackPointExtensionv1.xsd http://www.garmin.com/xmlschemas/PowerExtension/v1 http://www.garmin.com/xmlschemas/PowerExtensionv1.xsd http://www.topografix.com/GPX/gpx_style/0/2 http://www.topografix.com/GPX/gpx_style/0/2/gpx_style.xsd";
type XmlWriter = Writer<Vec<u8>>; type XmlWriter = Writer<Vec<u8>>;
@@ -89,10 +81,10 @@ type XmlWriter = Writer<Vec<u8>>;
/// Writes a file as GPX 1.1, in UTF-8. The surface, highway, SAC scale and MTB scale of the trackpoints are /// Writes a file as GPX 1.1, in UTF-8. The surface, highway, SAC scale and MTB scale of the trackpoints are
/// the names of their codes in `categories`. /// the names of their codes in `categories`.
/// ///
/// The segments are written as tracks, or with `options.as_route` as routes: a segment becomes a /// The routes are written as routes (`rte`): the anchors of their segment that are shown from the
/// `rte` that has the information of its track, its anchors are the `rtept` and the trackpoints /// zoom level [`ROUTE_POINT_MAX_ZOOM`] or a lower one are the `rtept`, and the trackpoints between
/// between two anchors are the `gpxx:rpt` of the `rtept` before them (which only have a /// two of them are the `gpxx:rpt` of the `rtept` before them (which only have a position, unlike
/// position, unlike the `rtept`). The first and last trackpoints always are `rtept`. /// the `rtept`). The first and last trackpoints always are `rtept`.
pub fn write(file: &File, categories: &TrackpointCategories, options: ExportOptions) -> Vec<u8> { pub fn write(file: &File, categories: &TrackpointCategories, options: ExportOptions) -> Vec<u8> {
let mut writer = Writer::new_with_indent(Vec::new(), b' ', 4); let mut writer = Writer::new_with_indent(Vec::new(), b' ', 4);
// writing to memory does not fail // writing to memory does not fail
@@ -100,9 +92,9 @@ pub fn write(file: &File, categories: &TrackpointCategories, options: ExportOpti
writer.into_inner() writer.into_inner()
} }
fn write_file( fn write_file<'a>(
w: &mut XmlWriter, w: &mut XmlWriter,
file: &File, file: &'a File,
categories: &TrackpointCategories, categories: &TrackpointCategories,
options: ExportOptions, options: ExportOptions,
) -> io::Result<()> { ) -> io::Result<()> {
@@ -136,20 +128,18 @@ fn write_file(
for wpt in file.wpt.iter() { for wpt in file.wpt.iter() {
write_waypoint(w, wpt, options)?; write_waypoint(w, wpt, options)?;
} }
for trk in &file.trk { // a single unnamed route or track is named like the file
// a single unnamed track is named like the file let alone = file.rte.len() + file.trk.len() == 1;
let name = match (&trk.info.name, file.trk.len()) { let name = |info: &'a crate::TrackInfo| match &info.name {
(Some(name), _) if !name.is_empty() => Some(name.as_str()), Some(name) if !name.is_empty() => Some(name.as_str()),
(_, 1) => Some(file.info.name.as_str()), _ if alone => Some(file.info.name.as_str()),
_ => None, _ => None,
}; };
if options.as_route { for rte in &file.rte {
for segment in trk.trkseg.iter().filter(|segment| !segment.is_empty()) { write_route(w, &rte.info, &rte.seg, name(&rte.info), categories, options)?;
write_route(w, trk, segment, name, categories, options)?;
}
} else {
write_track(w, trk, name, categories, options)?;
} }
for trk in &file.trk {
write_track(w, trk, name(&trk.info), categories, options)?;
} }
Ok(()) Ok(())
})?; })?;
@@ -311,22 +301,28 @@ fn write_track(
fn write_route( fn write_route(
w: &mut XmlWriter, w: &mut XmlWriter,
trk: &Track, info: &crate::TrackInfo,
segment: &crate::TrackSegment, segment: &crate::TrackSegment,
name: Option<&str>, name: Option<&str>,
categories: &TrackpointCategories, categories: &TrackpointCategories,
options: ExportOptions, options: ExportOptions,
) -> io::Result<()> { ) -> io::Result<()> {
w.create_element("rte").write_inner_content(|w| { w.create_element("rte").write_inner_content(|w| {
write_track_info(w, name, &trk.info)?; write_track_info(w, name, info)?;
let points: Vec<&Trackpoint> = segment.iter().collect(); let points: Vec<&Trackpoint> = segment.iter().collect();
let last = points.len() - 1; let last = points.len().saturating_sub(1);
let is_anchor = |i: usize| i == 0 || i == last || points[i].anchor.is_some(); let is_route_point = |i: usize| {
i == 0
|| i == last
|| points[i]
.anchor
.is_some_and(|zoom| zoom <= ROUTE_POINT_MAX_ZOOM)
};
let mut i = 0; let mut i = 0;
while i < points.len() { while i < points.len() {
// the trackpoints up to the next anchor // the trackpoints up to the next route point
let mut next = i + 1; let mut next = i + 1;
while next < points.len() && !is_anchor(next) { while next < points.len() && !is_route_point(next) {
next += 1; next += 1;
} }
write_point( write_point(
@@ -455,9 +451,7 @@ mod tests {
} }
fn points(file: &File) -> Vec<Trackpoint> { fn points(file: &File) -> Vec<Trackpoint> {
file.trk file.segments()
.iter()
.flat_map(|trk| &trk.trkseg)
.flat_map(|seg| seg.iter().cloned()) .flat_map(|seg| seg.iter().cloned())
.collect() .collect()
} }
@@ -595,11 +589,20 @@ mod tests {
assert!(file.exportable_data().power); assert!(file.exportable_data().power);
} }
fn as_route() -> ExportOptions { /// The file with each segment of its tracks as a route, which has the information of its
ExportOptions { /// track.
as_route: true, fn as_routes(mut file: File) -> File {
..ExportOptions::ALL file.rte = std::mem::take(&mut file.trk)
} .into_iter()
.flat_map(|trk| {
trk.trkseg.into_iter().map(move |seg| crate::Route {
info: trk.info.clone(),
seg,
..Default::default()
})
})
.collect();
file
} }
fn count(text: &str, pattern: &str) -> usize { fn count(text: &str, pattern: &str) -> usize {
@@ -607,19 +610,17 @@ mod tests {
} }
#[test] #[test]
fn test_export_as_route_writes_routes_instead_of_tracks() { fn test_routes_are_written_with_their_route_points_and_their_path() {
let (file, categories) = read("with_route_points"); let (file, categories) = read("with_route_points");
let text = written_text(&file, &categories, as_route()); let text = written_text(&file, &categories, ExportOptions::ALL);
assert_eq!(count(&text, "<rte>"), 1); assert_eq!(count(&text, "<rte>"), 1);
assert_eq!(count(&text, "<trk>"), 0); assert_eq!(count(&text, "<trk>"), 0);
assert_eq!(count(&text, "<trkseg>"), 0);
assert_eq!(count(&text, "<trkpt"), 0); assert_eq!(count(&text, "<trkpt"), 0);
// the anchors are the route points, the trackpoints between them the points of their path // the anchors are the route points, the trackpoints between them the points of their path
assert_eq!(count(&text, "<rtept"), 3); assert_eq!(count(&text, "<rtept"), 3);
assert_eq!(count(&text, "<gpxx:rpt"), 4); assert_eq!(count(&text, "<gpxx:rpt"), 4);
assert_eq!(count(&text, "<gpxx:RoutePointExtension>"), 2); assert_eq!(count(&text, "<gpxx:RoutePointExtension>"), 2);
// a route has the information of its track
assert!(text.contains("<name>Lausanne</name>")); assert!(text.contains("<name>Lausanne</name>"));
assert!(text.contains("<cmt>comment</cmt>")); assert!(text.contains("<cmt>comment</cmt>"));
assert!(text.contains("<desc>description</desc>")); assert!(text.contains("<desc>description</desc>"));
@@ -634,14 +635,14 @@ mod tests {
} }
#[test] #[test]
fn test_a_route_survives_being_exported_as_a_route() { fn test_a_route_survives_being_written() {
let (file, mut categories) = read("with_route_points"); let (file, mut categories) = read("with_route_points");
let written = write(&file, &categories, as_route()); let written = write(&file, &categories, ExportOptions::ALL);
let again = parse(&written, &mut categories).unwrap(); let again = parse(&written, &mut categories).unwrap();
assert_eq!(again.trk.len(), 1); assert_eq!(again.rte.len(), 1);
assert_eq!(again.trk[0].info, file.trk[0].info); assert_eq!(again.rte[0].info, file.rte[0].info);
let (a, b) = (&file.trk[0].trkseg[0], &again.trk[0].trkseg[0]); let (a, b) = (&file.rte[0].seg, &again.rte[0].seg);
assert_eq!(a.len(), b.len()); assert_eq!(a.len(), b.len());
for (a, b) in a.iter().zip(b.iter()) { for (a, b) in a.iter().zip(b.iter()) {
assert_eq!(a.coordinates.lat, b.coordinates.lat); assert_eq!(a.coordinates.lat, b.coordinates.lat);
@@ -659,85 +660,121 @@ mod tests {
} }
#[test] #[test]
fn test_tracks_become_one_route_per_segment_with_the_information_of_the_track() { fn test_the_anchors_shown_up_to_zoom_12_are_the_route_points() {
let (file, mut categories) = read("with_tracks_and_segments"); let (file, mut categories) = read("with_tracks_and_segments");
let segments: usize = file.trk.iter().map(|t| t.trkseg.len()).sum(); let file = as_routes(file);
assert!(segments > 2); assert!(file.rte.len() > 2);
let written = write(&file, &categories, as_route()); let written = write(&file, &categories, ExportOptions::ALL);
let text = String::from_utf8(written.clone()).unwrap();
assert_eq!(count(&text, "<rte>"), segments);
assert_eq!(count(&text, "<trk>"), 0);
// every route starts and ends with a route point
assert_eq!(count(&text, "</rte>"), segments);
let again = parse(&written, &mut categories).unwrap(); let again = parse(&written, &mut categories).unwrap();
assert_eq!(again.trk.len(), segments); assert_eq!(again.rte.len(), file.rte.len());
let mut routes = again.trk.iter(); let anchors = |s: &crate::TrackSegment, max_zoom: u8| {
for track in &file.trk {
for segment in &track.trkseg {
let route = routes.next().unwrap();
// the information of the track, repeated for each of its segments
assert_eq!(route.info.name, track.info.name);
assert_eq!(route.info.type_, track.info.type_);
let (a, b) = (segment, &route.trkseg[0]);
assert_eq!(a.len(), b.len());
for (a, b) in a.iter().zip(b.iter()) {
assert_eq!(
(a.coordinates.lat, a.coordinates.lng),
(b.coordinates.lat, b.coordinates.lng)
);
}
// the anchors of the segment are the ones of its route
let anchors = |s: &crate::TrackSegment| {
s.iter() s.iter()
.enumerate() .enumerate()
.filter(|(_, p)| p.anchor.is_some()) .filter(|(_, p)| p.anchor.is_some_and(|zoom| zoom <= max_zoom))
.map(|(i, _)| i) .map(|(i, _)| i)
.collect::<Vec<_>>() .collect::<Vec<_>>()
}; };
assert_eq!(anchors(a), anchors(b)); for (a, b) in file.rte.iter().zip(&again.rte) {
} assert_eq!(a.info.name, b.info.name);
assert_eq!(a.seg.len(), b.seg.len());
// the route points come back as anchors, the other anchors as points of the path
assert_eq!(
anchors(&a.seg, ROUTE_POINT_MAX_ZOOM),
anchors(&b.seg, u8::MAX)
);
} }
} }
#[test]
fn test_anchors_shown_from_a_higher_zoom_are_points_of_the_path() {
let (file, categories) = read("simple");
let mut file = as_routes(file);
let segment = &mut file.rte[0].seg;
segment.update_all(|_, point| point.anchor = None);
let len = segment.len();
segment.set_anchor(5, ROUTE_POINT_MAX_ZOOM);
segment.set_anchor(10, ROUTE_POINT_MAX_ZOOM + 1);
let text = written_text(&file, &categories, ExportOptions::ALL);
// the ends, and the anchor shown from zoom 12
assert_eq!(count(&text, "<rtept"), 3);
assert_eq!(count(&text, "<gpxx:rpt"), len - 3);
}
#[test] #[test]
fn test_the_ends_of_a_route_are_route_points_even_without_anchors() { fn test_the_ends_of_a_route_are_route_points_even_without_anchors() {
let (mut file, categories) = read("simple"); let (file, categories) = read("simple");
file.trk[0].trkseg[0].update_all(|_, point| point.anchor = None); let mut file = as_routes(file);
let len = file.trk[0].trkseg[0].len(); file.rte[0].seg.update_all(|_, point| point.anchor = None);
let text = written_text(&file, &categories, as_route()); let len = file.rte[0].seg.len();
let text = written_text(&file, &categories, ExportOptions::ALL);
assert_eq!(count(&text, "<rtept"), 2); assert_eq!(count(&text, "<rtept"), 2);
assert_eq!(count(&text, "<gpxx:rpt"), len - 2); assert_eq!(count(&text, "<gpxx:rpt"), len - 2);
// and a single trackpoint is a route of one point // and a single trackpoint is a route of one point
file.trk[0].trkseg[0].splice(1, len, vec![]); file.rte[0].seg.splice(1, len, vec![]);
let text = written_text(&file, &categories, as_route()); let text = written_text(&file, &categories, ExportOptions::ALL);
assert_eq!(count(&text, "<rtept"), 1); assert_eq!(count(&text, "<rtept"), 1);
assert_eq!(count(&text, "<gpxx:rpt"), 0); assert_eq!(count(&text, "<gpxx:rpt"), 0);
} }
#[test] #[test]
fn test_empty_segments_are_not_routes() { fn test_routes_keep_their_style() {
let (mut file, categories) = read("simple");
file.trk[0].trkseg.push(crate::TrackSegment::default());
let text = written_text(&file, &categories, as_route());
assert_eq!(count(&text, "<rte>"), 1);
}
#[test]
fn test_routes_have_the_style_of_their_track() {
let (file, categories) = read("with_style"); let (file, categories) = read("with_style");
let text = written_text(&file, &categories, as_route()); let file = as_routes(file);
let text = written_text(&file, &categories, ExportOptions::ALL);
assert!(count(&text, "<gpx_style:line>") >= 1); assert!(count(&text, "<gpx_style:line>") >= 1);
assert_eq!(count(&text, "<gpx_style:line>"), count(&text, "<rte>")); assert_eq!(count(&text, "<gpx_style:line>"), count(&text, "<rte>"));
let again = parse(text.as_bytes(), &mut Default::default()).unwrap(); let again = parse(text.as_bytes(), &mut Default::default()).unwrap();
assert_eq!(again.trk[0].info.color, file.trk[0].info.color); assert_eq!(again.rte[0].info.color, file.rte[0].info.color);
assert_eq!(again.trk[0].info.width, file.trk[0].info.width); assert_eq!(again.rte[0].info.width, file.rte[0].info.width);
}
#[test]
fn test_routes_are_written_as_routes_before_the_tracks() {
let (mut file, categories) = read("with_tracks");
let (routes, _) = read("with_routes");
file.rte = routes.rte.clone();
let text = written_text(&file, &categories, ExportOptions::ALL);
assert_eq!(count(&text, "<rte>"), routes.rte.len());
assert_eq!(count(&text, "<trk>"), file.trk.len());
assert!(text.rfind("</rte>").unwrap() < text.find("<trk>").unwrap());
let again = parse(text.as_bytes(), &mut categories.clone()).unwrap();
assert_eq!(again.rte.len(), file.rte.len());
assert_eq!(again.trk.len(), file.trk.len());
for (a, b) in file.rte.iter().zip(&again.rte) {
assert_eq!(a.info, b.info);
same_points(
&a.seg.iter().cloned().collect::<Vec<_>>(),
&b.seg.iter().cloned().collect::<Vec<_>>(),
);
}
same_points(&points(&file), &points(&again));
}
#[test]
fn test_an_empty_route_is_written_and_a_single_route_is_named_like_the_file() {
let mut file = File::default();
file.info.name = "file".into();
file.rte.push(crate::Route::default());
let text = written_text(&file, &Default::default(), ExportOptions::ALL);
assert_eq!(count(&text, "<rte>"), 1);
assert_eq!(count(&text, "<rtept"), 0);
let again = parse(text.as_bytes(), &mut Default::default()).unwrap();
assert_eq!(again.rte.len(), 1);
assert_eq!(again.rte[0].info.name.as_deref(), Some("file"));
// not when there are others
file.trk.push(Track::default());
let text = written_text(&file, &Default::default(), ExportOptions::ALL);
// the metadata only
assert_eq!(count(&text, "<name>file</name>"), 1);
} }
#[test] #[test]
fn test_route_points_keep_their_data_and_the_options_apply_to_them() { fn test_route_points_keep_their_data_and_the_options_apply_to_them() {
let (file, categories) = read("with_hr"); let (file, categories) = read("with_hr");
let with_all = written_text(&file, &categories, as_route()); let file = as_routes(file);
let with_all = written_text(&file, &categories, ExportOptions::ALL);
assert!(with_all.contains("<gpxtpx:hr>")); assert!(with_all.contains("<gpxtpx:hr>"));
let without = written_text( let without = written_text(
&file, &file,
@@ -745,22 +782,10 @@ mod tests {
ExportOptions { ExportOptions {
hr: false, hr: false,
time: false, time: false,
as_route: true,
..ExportOptions::ALL ..ExportOptions::ALL
}, },
); );
assert!(!without.contains("<gpxtpx:hr>")); assert!(!without.contains("<gpxtpx:hr>"));
assert!(!without.contains("<time>")); assert!(!without.contains("<time>"));
} }
#[test]
fn test_the_route_option_is_not_data() {
let (file, _) = read("with_route_points");
assert!(!file.exportable_data().as_route);
// it is a way to write the data, never part of them
let all = std::hint::black_box(ExportOptions::ALL);
assert!(!all.as_route && !ExportOptions::NONE.as_route);
assert!(as_route().union(ExportOptions::NONE).as_route);
assert!(!ExportOptions::ALL.union(ExportOptions::NONE).as_route);
}
} }
+7
View File
@@ -36,4 +36,11 @@ pub enum Action<'a> {
file_ids: Vec<FileId>, file_ids: Vec<FileId>,
index: usize, index: usize,
}, },
/// Names a file in all the states of the history, as if it had always been called so (see
/// [`crate::History::rename`]). Not undoable: for a file that is given a name from the outside,
/// like the name of the file it is written to.
Rename {
file_id: FileId,
name: String,
},
} }
+128
View File
@@ -0,0 +1,128 @@
use std::{collections::HashSet, rc::Rc};
use crate::{Apply, CommandError, FileId, Route, Selection, State, Track};
/// Converts the selected routes to tracks, or the selected tracks or segments to routes, in their
/// file:
///
/// - a route becomes a track with its segment. The new tracks go before the other tracks, right
/// after the routes;
/// - a track becomes one route per segment, as a route has a single segment, and goes away. A
/// selected segment becomes a route and leaves its track, which stays even if it has no segment
/// left. The routes have the information of their track, and go after the other routes.
///
/// The segments keep their ids, the new routes and tracks are selected. There is nothing to do
/// for the other selections, or for tracks without segments.
#[derive(Debug)]
pub struct Convert;
impl Apply for Convert {
fn apply(self, state: &mut State) -> Result<(), CommandError> {
let selection = state.selection.clone();
let next = match &selection {
Selection::Route { file_id, rte_ids } => {
let file = Rc::make_mut(
state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?,
);
let (converted, kept) = std::mem::take(&mut file.rte)
.into_iter()
.partition::<Vec<_>, _>(|rte| rte_ids.contains(&rte.id));
file.rte = kept;
let tracks: Vec<Track> = converted
.into_iter()
.map(|rte| Track {
id: Default::default(),
info: rte.info,
trkseg: vec![rte.seg],
})
.collect();
let trk_ids: HashSet<_> = tracks.iter().map(|trk| trk.id).collect();
file.trk.splice(0..0, tracks);
Selection::Track {
file_id: *file_id,
trk_ids,
}
}
Selection::Track { file_id, trk_ids } => {
let file = Rc::make_mut(
state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?,
);
let (converted, kept) = std::mem::take(&mut file.trk)
.into_iter()
.partition::<Vec<_>, _>(|trk| trk_ids.contains(&trk.id));
file.trk = kept;
let routes = converted.into_iter().flat_map(|trk| {
trk.trkseg.into_iter().map(move |seg| Route {
id: Default::default(),
info: trk.info.clone(),
seg,
})
});
add_routes(&mut file.rte, *file_id, routes)
}
Selection::TrackSegment {
file_id,
trk_id,
trkseg_ids,
} => {
let file = Rc::make_mut(
state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?,
);
let trk = file
.trk
.iter_mut()
.find(|trk| trk.id == *trk_id)
.ok_or(CommandError::NothingToDo)?;
let (converted, kept) = std::mem::take(&mut trk.trkseg)
.into_iter()
.partition::<Vec<_>, _>(|seg| trkseg_ids.contains(&seg.id));
trk.trkseg = kept;
let info = trk.info.clone();
let routes = converted.into_iter().map(|seg| Route {
id: Default::default(),
info: info.clone(),
seg,
});
add_routes(&mut file.rte, *file_id, routes)
}
Selection::Empty
| Selection::File { .. }
| Selection::Waypoints { .. }
| Selection::Waypoint { .. } => return Err(CommandError::NothingToDo),
};
let empty = match &next {
Selection::Route { rte_ids, .. } => rte_ids.is_empty(),
Selection::Track { trk_ids, .. } => trk_ids.is_empty(),
_ => true,
};
if empty {
// nothing was converted (the changed copy of the file is dropped)
return Err(CommandError::NothingToDo);
}
*state.selection = next;
Ok(())
}
}
/// Adds the routes after the other routes, and gives the selection of them.
fn add_routes(
rte: &mut Vec<Route>,
file_id: FileId,
routes: impl Iterator<Item = Route>,
) -> Selection {
let start = rte.len();
rte.extend(routes);
Selection::Route {
file_id,
rte_ids: rte[start..].iter().map(|rte| rte.id).collect(),
}
}
+24 -7
View File
@@ -1,4 +1,6 @@
use crate::{Apply, CommandError, Edit, Editor, File, State, Track, update_selected}; use crate::{
Apply, CommandError, Edit, Editor, File, Route, State, Track, TrackInfo, update_selected,
};
#[derive(Debug)] #[derive(Debug)]
pub struct Metadata<'a> { pub struct Metadata<'a> {
@@ -13,21 +15,33 @@ impl Apply for Metadata<'_> {
} }
} }
impl Metadata<'_> {
fn set(&self, info: &mut TrackInfo) -> Edit {
info.name = Some(self.name.to_owned());
info.desc = Some(self.desc.to_owned());
Edit::Changed
}
}
impl Editor for Metadata<'_> { impl Editor for Metadata<'_> {
fn file(&mut self, file: &mut File) -> Edit { fn file(&mut self, file: &mut File) -> Edit {
file.info.name = self.name.to_owned(); file.info.name = self.name.to_owned();
file.info.desc = Some(self.desc.to_owned()); file.info.desc = Some(self.desc.to_owned());
// a single track is named like the file // a single route or track is named like the file
if let [track] = file.trk.as_mut_slice() { match (file.rte.as_mut_slice(), file.trk.as_mut_slice()) {
track.info.name = Some(self.name.to_owned()); ([route], []) => route.info.name = Some(self.name.to_owned()),
([], [track]) => track.info.name = Some(self.name.to_owned()),
_ => {}
} }
Edit::Changed Edit::Changed
} }
fn route(&mut self, route: &mut Route) -> Edit {
self.set(&mut route.info)
}
fn track(&mut self, track: &mut Track) -> Edit { fn track(&mut self, track: &mut Track) -> Edit {
track.info.name = Some(self.name.to_owned()); self.set(&mut track.info)
track.info.desc = Some(self.desc.to_owned());
Edit::Changed
} }
} }
@@ -45,12 +59,14 @@ mod tests {
New { New {
name: "first", name: "first",
trackpoint: None, trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
New { New {
name: "second", name: "second",
trackpoint: None, trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); // now selected .unwrap(); // now selected
@@ -108,6 +124,7 @@ mod tests {
New { New {
name: "file", name: "file",
trackpoint: None, trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
+4
View File
@@ -1,9 +1,13 @@
mod convert;
mod metadata; mod metadata;
mod new_route;
mod new_track; mod new_track;
mod new_track_segment; mod new_track_segment;
mod style; mod style;
pub use convert::*;
pub use metadata::*; pub use metadata::*;
pub use new_route::*;
pub use new_track::*; pub use new_track::*;
pub use new_track_segment::*; pub use new_track_segment::*;
pub use style::*; pub use style::*;
@@ -0,0 +1,55 @@
use crate::{Apply, CommandError, Edit, Editor, File, Route, State, update_selected};
use super::new_track::common_style;
/// Adds an empty route to the selected files, with the style that their routes and tracks agree
/// on (see [`crate::NewTrack`]).
#[derive(Debug)]
pub struct NewRoute;
impl Apply for NewRoute {
fn apply(mut self, state: &mut State) -> Result<(), CommandError> {
update_selected(state, &mut self);
Ok(())
}
}
impl Editor for NewRoute {
fn file(&mut self, file: &mut File) -> Edit {
file.rte.push(Route {
info: common_style(file),
..Default::default()
});
Edit::Changed
}
}
#[cfg(test)]
mod tests {
use crate::{New, engine::command::fixture::Fixture};
use super::*;
#[test]
fn test_new_route_has_the_style_of_the_tracks_and_an_empty_segment() {
let mut fx = Fixture::default();
New {
name: "file",
trackpoint: None,
route: false,
}
.apply(&mut fx.state())
.unwrap();
crate::NewTrack.apply(&mut fx.state()).unwrap();
let file = std::rc::Rc::make_mut(fx.files.values_mut().next().unwrap());
file.trk[0].info.color = Some("ff0000".into());
NewRoute.apply(&mut fx.state()).unwrap();
NewRoute.apply(&mut fx.state()).unwrap();
let file = fx.files.values().next().unwrap();
assert_eq!(file.rte.len(), 2);
assert_ne!(file.rte[0].id, file.rte[1].id);
assert!(file.rte[0].seg.is_empty());
assert_eq!(file.rte[1].info.color.as_deref(), Some("ff0000"));
}
}
+34 -11
View File
@@ -1,4 +1,4 @@
use crate::{Apply, CommandError, Edit, Editor, File, State, Track, update_selected}; use crate::{Apply, CommandError, Edit, Editor, File, State, Track, TrackInfo, update_selected};
#[derive(Debug)] #[derive(Debug)]
pub struct NewTrack; pub struct NewTrack;
@@ -12,20 +12,41 @@ impl Apply for NewTrack {
impl Editor for NewTrack { impl Editor for NewTrack {
fn file(&mut self, file: &mut File) -> Edit { fn file(&mut self, file: &mut File) -> Edit {
let mut track = Track::default(); file.trk.push(Track {
track.info.color = common(file, |t| &t.info.color); info: common_style(file),
track.info.opacity = common(file, |t| &t.info.opacity); ..Default::default()
track.info.width = common(file, |t| &t.info.width); });
file.trk.push(track);
Edit::Changed Edit::Changed
} }
} }
/// Returns the value of a style attribute if all existing tracks agree on it. /// The style that all the routes and tracks of the file agree on: each attribute is set if they
fn common<T: Clone + PartialEq>(file: &File, get: impl Fn(&Track) -> &Option<T>) -> Option<T> { /// all have the same value for it.
let mut tracks = file.trk.iter(); pub(super) fn common_style(file: &File) -> TrackInfo {
let first = get(tracks.next()?); let infos: Vec<&TrackInfo> = file
tracks.all(|t| get(t) == first).then(|| first.clone())? .rte
.iter()
.map(|rte| &rte.info)
.chain(file.trk.iter().map(|trk| &trk.info))
.collect();
TrackInfo {
color: common(&infos, |info| &info.color),
opacity: common(&infos, |info| &info.opacity),
width: common(&infos, |info| &info.width),
..Default::default()
}
}
/// Returns the value of a style attribute if all the infos agree on it.
fn common<T: Clone + PartialEq>(
infos: &[&TrackInfo],
get: impl Fn(&TrackInfo) -> &Option<T>,
) -> Option<T> {
let mut infos = infos.iter();
let first = get(infos.next()?);
infos
.all(|info| get(info) == first)
.then(|| first.clone())?
} }
#[cfg(test)] #[cfg(test)]
@@ -40,6 +61,7 @@ mod tests {
New { New {
name: "file", name: "file",
trackpoint: None, trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
@@ -56,6 +78,7 @@ mod tests {
New { New {
name: "file", name: "file",
trackpoint: None, trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
@@ -29,6 +29,7 @@ mod tests {
New { New {
name: "file", name: "file",
trackpoint: None, trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
+16 -6
View File
@@ -1,4 +1,4 @@
use crate::{Apply, CommandError, Edit, Editor, State, Track, update_selected}; use crate::{Apply, CommandError, Edit, Editor, Route, State, Track, TrackInfo, update_selected};
#[derive(Debug)] #[derive(Debug)]
pub struct Style<'a> { pub struct Style<'a> {
@@ -14,21 +14,31 @@ impl Apply for Style<'_> {
} }
} }
impl Editor for Style<'_> { impl Style<'_> {
fn track(&mut self, track: &mut Track) -> Edit { fn set(&self, info: &mut TrackInfo) -> Edit {
if let Some(color) = self.color { if let Some(color) = self.color {
track.info.color = Some(color.to_owned()); info.color = Some(color.to_owned());
} }
if self.opacity.is_some() { if self.opacity.is_some() {
track.info.opacity = self.opacity; info.opacity = self.opacity;
} }
if self.width.is_some() { if self.width.is_some() {
track.info.width = self.width; info.width = self.width;
} }
Edit::Changed Edit::Changed
} }
} }
impl Editor for Style<'_> {
fn route(&mut self, route: &mut Route) -> Edit {
self.set(&mut route.info)
}
fn track(&mut self, track: &mut Track) -> Edit {
self.set(&mut track.info)
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::collections::HashSet; use std::collections::HashSet;
+18 -1
View File
@@ -3,7 +3,7 @@ use std::rc::Rc;
use crate::{Apply, CommandError, FileId, Files, Selection, State, Waypoint}; use crate::{Apply, CommandError, FileId, Files, Selection, State, Waypoint};
/// Deletes the selected elements. With `whole_files`, the files holding the selected elements /// Deletes the selected elements. With `whole_files`, the files holding the selected elements
/// are deleted instead, even if only a track or a waypoint is selected. /// are deleted instead, even if only a route, a track or a waypoint is selected.
#[derive(Debug)] #[derive(Debug)]
pub struct Delete { pub struct Delete {
pub whole_files: bool, pub whole_files: bool,
@@ -23,6 +23,21 @@ impl Apply for Delete {
state.order.0.retain(|id| !file_ids.contains(id)); state.order.0.retain(|id| !file_ids.contains(id));
Selection::Empty Selection::Empty
} }
Selection::Route { file_id, rte_ids } => {
let file = state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?;
let file = Rc::make_mut(file);
let len = file.rte.len();
file.rte.retain(|rte| !rte_ids.contains(&rte.id));
if file.rte.len() == len {
return Err(CommandError::NothingToDo);
}
Selection::File {
file_ids: [*file_id].into(),
}
}
Selection::Track { file_id, trk_ids } => { Selection::Track { file_id, trk_ids } => {
let file = state let file = state
.files .files
@@ -131,6 +146,7 @@ mod tests {
crate::New { crate::New {
name: "keep", name: "keep",
trackpoint: None, trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
@@ -265,6 +281,7 @@ mod tests {
crate::New { crate::New {
name: "keep", name: "keep",
trackpoint: None, trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
@@ -31,12 +31,14 @@ mod tests {
crate::New { crate::New {
name: "a", name: "a",
trackpoint: None, trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
crate::New { crate::New {
name: "b", name: "b",
trackpoint: None, trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
+27 -2
View File
@@ -1,8 +1,8 @@
use std::{collections::HashSet, rc::Rc}; use std::{collections::HashSet, rc::Rc};
use crate::{ use crate::{
Apply, CommandError, FileId, Files, Selection, State, Waypoint, copy_file, copy_segment, Apply, CommandError, FileId, Files, Selection, State, Waypoint, copy_file, copy_route,
copy_track, copy_waypoint, copy_segment, copy_track, copy_waypoint,
}; };
#[derive(Debug)] #[derive(Debug)]
@@ -35,6 +35,30 @@ impl Apply for Duplicate {
state.order.0.splice(at..at, order); state.order.0.splice(at..at, order);
Selection::File { file_ids: copies } Selection::File { file_ids: copies }
} }
Selection::Route { file_id, rte_ids } => {
let file = state
.files
.get_mut(file_id)
.ok_or(CommandError::NothingToDo)?;
let file = Rc::make_mut(file);
let mut copies = HashSet::new();
append_copies(
&mut file.rte,
|rte| rte_ids.contains(&rte.id),
|rte| {
let copy = copy_route(rte);
copies.insert(copy.id);
copy
},
);
if copies.is_empty() {
return Err(CommandError::NothingToDo);
}
Selection::Route {
file_id: *file_id,
rte_ids: copies,
}
}
Selection::Track { file_id, trk_ids } => { Selection::Track { file_id, trk_ids } => {
let file = state let file = state
.files .files
@@ -193,6 +217,7 @@ mod tests {
crate::New { crate::New {
name, name,
trackpoint: None, trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
+3
View File
@@ -85,6 +85,7 @@ mod tests {
crate::New { crate::New {
name: "keep", name: "keep",
trackpoint: None, trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
@@ -140,6 +141,7 @@ mod tests {
crate::New { crate::New {
name: "before", name: "before",
trackpoint: None, trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
@@ -189,6 +191,7 @@ mod tests {
crate::New { crate::New {
name: "keep", name: "keep",
trackpoint: None, trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
@@ -9,6 +9,8 @@ use crate::{
pub enum MoveTarget { pub enum MoveTarget {
/// The list of files. /// The list of files.
Files { index: usize }, Files { index: usize },
/// The routes of a file.
Routes { file_id: FileId, index: usize },
/// The tracks of a file. /// The tracks of a file.
Tracks { file_id: FileId, index: usize }, Tracks { file_id: FileId, index: usize },
/// The segments of a track. /// The segments of a track.
@@ -26,9 +28,10 @@ pub enum MoveTarget {
/// ///
/// What can go where: /// What can go where:
/// - files: among the files; /// - files: among the files;
/// - tracks: among the files (each track becomes a file), or among the tracks of a file; /// - routes and tracks: among the files (each one becomes a file), or among the routes or the
/// - segments: among the files (each segment becomes a file), among the tracks of a file (each /// tracks of a file, where they keep their kind (see [`Destination`]);
/// segment becomes a track), or among the segments of a track; /// - segments: among the files (each segment becomes a file), among the routes or the tracks of a
/// file (each segment becomes a route or a track), or among the segments of a track;
/// - waypoints, or all the waypoints of a file (the waypoints node): among the waypoints of a /// - waypoints, or all the waypoints of a file (the waypoints node): among the waypoints of a
/// file. /// file.
#[derive(Debug)] #[derive(Debug)]
@@ -41,6 +44,9 @@ impl From<MoveTarget> for Destination {
fn from(target: MoveTarget) -> Self { fn from(target: MoveTarget) -> Self {
match target { match target {
MoveTarget::Files { index } => Destination::Files(Place::Index(index)), MoveTarget::Files { index } => Destination::Files(Place::Index(index)),
MoveTarget::Routes { file_id, index } => {
Destination::Routes(file_id, Place::Index(index))
}
MoveTarget::Tracks { file_id, index } => { MoveTarget::Tracks { file_id, index } => {
Destination::Tracks(file_id, Place::Index(index)) Destination::Tracks(file_id, Place::Index(index))
} }
+53 -6
View File
@@ -1,12 +1,16 @@
use crate::{Apply, CommandError, File, LngLat, State, Track, TrackSegment, Trackpoint, produce}; use crate::{
Apply, CommandError, File, LngLat, Route, State, Track, TrackSegment, Trackpoint, produce,
};
/// Creates an empty file and selects it. With a `trackpoint`, the file starts with a track and a /// Creates an empty file and selects it. With `route`, the file starts with a route, which is
/// segment that hold it: it is how the routing tool starts a route on an empty map, in a single /// empty. With a `trackpoint`, the file starts with a segment that holds it, the one of its route
/// undo step. /// with `route` or of a track otherwise: it is how the routing tool starts a route on an empty map,
/// in a single undo step.
#[derive(Debug)] #[derive(Debug)]
pub struct New<'a> { pub struct New<'a> {
pub name: &'a str, pub name: &'a str,
pub trackpoint: Option<NewTrackpoint>, pub trackpoint: Option<NewTrackpoint>,
pub route: bool,
} }
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
@@ -21,8 +25,8 @@ impl Apply for New<'_> {
produce(state, |_| { produce(state, |_| {
let mut file = File::default(); let mut file = File::default();
file.info.name = self.name.to_owned(); file.info.name = self.name.to_owned();
if let Some(point) = self.trackpoint {
let mut segment = TrackSegment::default(); let mut segment = TrackSegment::default();
if let Some(point) = self.trackpoint {
// the ends of a segment are anchors of the routing tool // the ends of a segment are anchors of the routing tool
segment.splice( segment.splice(
0, 0,
@@ -36,6 +40,13 @@ impl Apply for New<'_> {
..Default::default() ..Default::default()
}], }],
); );
}
if self.route {
file.rte.push(Route {
seg: segment,
..Default::default()
});
} else if self.trackpoint.is_some() {
file.trk.push(Track { file.trk.push(Track {
trkseg: vec![segment], trkseg: vec![segment],
..Default::default() ..Default::default()
@@ -59,7 +70,8 @@ mod tests {
assert!( assert!(
New { New {
name: "new", name: "new",
trackpoint: None trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.is_ok() .is_ok()
@@ -83,6 +95,7 @@ mod tests {
New { New {
name: "new", name: "new",
trackpoint: Some(point), trackpoint: Some(point),
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
@@ -98,4 +111,38 @@ mod tests {
assert_eq!(segment[0].anchor, Some(0)); assert_eq!(segment[0].anchor, Some(0));
assert_eq!(fx.selected_files(), [file.id].into()); assert_eq!(fx.selected_files(), [file.id].into());
} }
#[test]
fn test_new_with_a_route() {
let mut fx = Fixture::default();
New {
name: "new",
trackpoint: None,
route: true,
}
.apply(&mut fx.state())
.unwrap();
let file = fx.files.values().next().unwrap();
assert_eq!((file.rte.len(), file.trk.len()), (1, 0));
assert!(file.rte[0].seg.is_empty());
assert_eq!(fx.selected_files(), [file.id].into());
// the trackpoint goes to the route
let mut fx = Fixture::default();
New {
name: "new",
trackpoint: Some(NewTrackpoint {
lng: 1.0,
lat: 2.0,
ele: 3.0,
}),
route: true,
}
.apply(&mut fx.state())
.unwrap();
let file = fx.files.values().next().unwrap();
assert_eq!((file.rte.len(), file.trk.len()), (1, 0));
assert_eq!(file.rte[0].seg.len(), 1);
assert_eq!(file.rte[0].seg[0].anchor, Some(0));
}
} }
+41 -10
View File
@@ -1,18 +1,21 @@
use std::{collections::HashSet, hash::Hash}; use std::{collections::HashSet, hash::Hash};
use crate::{ use crate::{
Apply, ClipboardContent, CommandError, Destination, FileId, Place, Selection, State, TrackId, Apply, ClipboardContent, CommandError, Destination, FileId, Place, RouteId, Selection, State,
TrackSegmentId, WaypointId, transfer, waypoint_ids, TrackId, TrackSegmentId, WaypointId, transfer, waypoint_ids,
}; };
/// Pastes what was copied or cut (see [`crate::Clipboard`]). /// Pastes what was copied or cut (see [`crate::Clipboard`]).
/// ///
/// Where it goes depends on the selection, see [`crate::Clipboard::can_paste`]: /// Where it goes depends on the selection, see [`crate::Clipboard::can_paste`]:
/// - nothing selected: the files are copied, and tracks and segments become new files (named /// - nothing selected: the files are copied, and routes, tracks and segments become new files
/// like their track, or like the file they come from); /// (named like their route or track, or like the file they come from);
/// - a file: the tracks are added to it, segments become new tracks in it, and the waypoints are /// - a file: the routes and tracks are added to it, segments become new tracks in it, and the
/// added to its own; /// waypoints are added to its own;
/// - a track: the segments are added to it, tracks go after it in its file; /// - a route: routes, and segments as new routes, go after it in its file, tracks go before the
/// other tracks (right after the routes);
/// - a track: the segments are added to it, tracks go after it in its file, and routes after the
/// other routes;
/// - a segment: the segments go after it in its track; /// - a segment: the segments go after it in its track;
/// - the waypoints or a waypoint: the waypoints are added to their file, after the waypoint. /// - the waypoints or a waypoint: the waypoints are added to their file, after the waypoint.
/// ///
@@ -46,6 +49,14 @@ impl Apply for Paste {
} }
} }
fn route_ids_of(state: &State, file_id: FileId) -> Vec<RouteId> {
state
.files
.get(&file_id)
.map(|file| file.rte.iter().map(|rte| rte.id).collect())
.unwrap_or_default()
}
fn track_ids_of(state: &State, file_id: FileId) -> Vec<TrackId> { fn track_ids_of(state: &State, file_id: FileId) -> Vec<TrackId> {
state state
.files .files
@@ -79,14 +90,34 @@ fn destination(state: &State, content: &ClipboardContent) -> Result<Destination,
}; };
Ok(match (content, &*state.selection) { Ok(match (content, &*state.selection) {
(ClipboardContent::Files(_), _) => Destination::Files(Place::End), (ClipboardContent::Files(_), _) => Destination::Files(Place::End),
(ClipboardContent::Tracks(_) | ClipboardContent::Segments(_), Selection::Empty) => { (
Destination::Files(Place::End) ClipboardContent::Routes(_)
| ClipboardContent::Tracks(_)
| ClipboardContent::Segments(_),
Selection::Empty,
) => Destination::Files(Place::End),
(ClipboardContent::Routes(_), Selection::File { file_ids }) => {
Destination::Routes(last_file(file_ids)?, Place::End)
}
// routes and tracks keep their kind (see `Destination`)
(
ClipboardContent::Routes(_)
| ClipboardContent::Tracks(_)
| ClipboardContent::Segments(_),
Selection::Route { file_id, rte_ids },
) => {
let last =
last_selected(route_ids_of(state, *file_id).into_iter(), rte_ids).ok_or(nothing)?;
Destination::Routes(*file_id, Place::After(last))
} }
( (
ClipboardContent::Tracks(_) | ClipboardContent::Segments(_), ClipboardContent::Tracks(_) | ClipboardContent::Segments(_),
Selection::File { file_ids }, Selection::File { file_ids },
) => Destination::Tracks(last_file(file_ids)?, Place::End), ) => Destination::Tracks(last_file(file_ids)?, Place::End),
(ClipboardContent::Tracks(_), Selection::Track { file_id, trk_ids }) => { (
ClipboardContent::Routes(_) | ClipboardContent::Tracks(_),
Selection::Track { file_id, trk_ids },
) => {
let last = let last =
last_selected(track_ids_of(state, *file_id).into_iter(), trk_ids).ok_or(nothing)?; last_selected(track_ids_of(state, *file_id).into_iter(), trk_ids).ok_or(nothing)?;
Destination::Tracks(*file_id, Place::After(last)) Destination::Tracks(*file_id, Place::After(last))
+10 -3
View File
@@ -7,7 +7,8 @@ use crate::{Apply, CommandError, FileId, Load, State};
pub struct ExternalFile<'a> { pub struct ExternalFile<'a> {
pub id: FileId, pub id: FileId,
pub data: &'a [u8], pub data: &'a [u8],
/// Name of the file when the data has none (see [`Load`]). /// Name of the file outside (the name of the file on the disk, without its extension), which
/// is its name here whatever the data says: a file has a single name.
pub name: &'a str, pub name: &'a str,
} }
@@ -15,7 +16,8 @@ pub struct ExternalFile<'a> {
/// that are given replace the ones of the same id (which stay where they are in the order, and keep /// that are given replace the ones of the same id (which stay where they are in the order, and keep
/// their id), or are added at the end if there are none; the ones to `remove` are deleted. /// their id), or are added at the end if there are none; the ones to `remove` are deleted.
/// ///
/// Unlike [`crate::LoadFiles`], the selection is left alone (what it holds of a file that was /// Unlike [`crate::LoadFiles`], the files are named as they are outside (see [`ExternalFile`]), and
/// the selection is left alone (what it holds of a file that was
/// replaced or deleted is dropped by the engine afterwards, as with any edit). /// replaced or deleted is dropped by the engine afterwards, as with any edit).
/// ///
/// The files that cannot be read are skipped, and the files that are here stay as they are. It is an /// The files that cannot be read are skipped, and the files that are here stay as they are. It is an
@@ -38,6 +40,9 @@ impl Apply for SyncFiles<'_> {
match load.parse(state.categories) { match load.parse(state.categories) {
Ok(mut file) => { Ok(mut file) => {
file.id = external.id; file.id = external.id;
if !external.name.trim().is_empty() {
file.info.name = external.name.to_owned();
}
if state.files.insert(external.id, Rc::new(file)).is_none() { if state.files.insert(external.id, Rc::new(file)).is_none() {
state.order.0.push(external.id); state.order.0.push(external.id);
} }
@@ -75,6 +80,7 @@ mod tests {
New { New {
name, name,
trackpoint: None, trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
@@ -101,7 +107,8 @@ mod tests {
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
assert_eq!(fx.order.0, [first, second]); assert_eq!(fx.order.0, [first, second]);
assert_eq!(fx.files[&first].info.name, "from disk"); // named as it is outside, not as the data says
assert_eq!(fx.files[&first].info.name, "name");
assert_eq!(fx.files[&first].id, first); assert_eq!(fx.files[&first].id, first);
// the selection is not changed // the selection is not changed
assert_eq!(fx.selected_files(), [second].into()); assert_eq!(fx.selected_files(), [second].into());
+131 -30
View File
@@ -5,9 +5,9 @@
use std::{collections::HashSet, rc::Rc}; use std::{collections::HashSet, rc::Rc};
use crate::{ use crate::{
ClipboardContent, ClipboardSegment, ClipboardTrack, CommandError, File, FileId, Selection, ClipboardContent, ClipboardRoute, ClipboardSegment, ClipboardTrack, CommandError, File, FileId,
State, Track, TrackId, TrackSegment, TrackSegmentId, Waypoint, WaypointId, copy_file, Route, RouteId, Selection, State, Track, TrackId, TrackSegment, TrackSegmentId, Waypoint,
copy_segment, copy_track, copy_waypoint, WaypointId, copy_file, copy_route, copy_segment, copy_track, copy_waypoint,
}; };
/// Where elements are put, among the others of the same list. /// Where elements are put, among the others of the same list.
@@ -37,15 +37,20 @@ impl<T: Copy + PartialEq> Place<T> {
/// Where elements go. What can go where: /// Where elements go. What can go where:
/// - files: among the files; /// - files: among the files;
/// - routes: among the files (each route becomes a file, named like the route, or like the file
/// it comes from), or among the routes of a file. Among the tracks of a file, they stay routes,
/// which come before the tracks: they go after the other routes;
/// - tracks: among the files (each track becomes a file, named like the track, or like the file /// - tracks: among the files (each track becomes a file, named like the track, or like the file
/// it comes from), or among the tracks of a file; /// it comes from), or among the tracks of a file. Among the routes of a file, they stay tracks,
/// which come after the routes: they go before the other tracks;
/// - segments: among the files (each segment becomes a file with a track, named like its track, /// - segments: among the files (each segment becomes a file with a track, named like its track,
/// or like its file), among the tracks of a file (each segment becomes a track), or among the /// or like its file), among the routes or the tracks of a file (each segment becomes a route or
/// segments of a track; /// a track), or among the segments of a track;
/// - waypoints: among the waypoints of a file. /// - waypoints: among the waypoints of a file.
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub enum Destination { pub enum Destination {
Files(Place<FileId>), Files(Place<FileId>),
Routes(FileId, Place<RouteId>),
Tracks(FileId, Place<TrackId>), Tracks(FileId, Place<TrackId>),
Segments(FileId, TrackId, Place<TrackSegmentId>), Segments(FileId, TrackId, Place<TrackSegmentId>),
Waypoints(FileId, Place<WaypointId>), Waypoints(FileId, Place<WaypointId>),
@@ -93,6 +98,14 @@ pub fn remove_elements(state: &mut State, content: &ClipboardContent) {
state.files.retain(|id, _| !ids.contains(id)); state.files.retain(|id, _| !ids.contains(id));
state.order.0.retain(|id| !ids.contains(id)); state.order.0.retain(|id| !ids.contains(id));
} }
ClipboardContent::Routes(routes) => {
let ids: HashSet<RouteId> = routes.iter().map(|rte| rte.route.id).collect();
for file in state.files.values_mut() {
if file.rte.iter().any(|rte| ids.contains(&rte.id)) {
Rc::make_mut(file).rte.retain(|rte| !ids.contains(&rte.id));
}
}
}
ClipboardContent::Tracks(tracks) => { ClipboardContent::Tracks(tracks) => {
let ids: HashSet<TrackId> = tracks.iter().map(|trk| trk.track.id).collect(); let ids: HashSet<TrackId> = tracks.iter().map(|trk| trk.track.id).collect();
for file in state.files.values_mut() { for file in state.files.values_mut() {
@@ -141,7 +154,9 @@ pub fn transfer(
// the destination has to be there, before anything is changed // the destination has to be there, before anything is changed
let exists = match to { let exists = match to {
Destination::Files(_) => true, Destination::Files(_) => true,
Destination::Tracks(file_id, _) => state.files.contains_key(&file_id), Destination::Routes(file_id, _) | Destination::Tracks(file_id, _) => {
state.files.contains_key(&file_id)
}
Destination::Segments(file_id, trk_id, _) => state Destination::Segments(file_id, trk_id, _) => state
.files .files
.get(&file_id) .get(&file_id)
@@ -152,17 +167,21 @@ pub fn transfer(
(content, to), (content, to),
(ClipboardContent::Files(_), Destination::Files(_)) (ClipboardContent::Files(_), Destination::Files(_))
| ( | (
ClipboardContent::Tracks(_), ClipboardContent::Routes(_) | ClipboardContent::Tracks(_),
Destination::Files(_) | Destination::Tracks(..) Destination::Files(_) | Destination::Routes(..) | Destination::Tracks(..)
) )
| ( | (
ClipboardContent::Segments(_), ClipboardContent::Segments(_),
Destination::Files(_) | Destination::Tracks(..) | Destination::Segments(..), Destination::Files(_)
| Destination::Routes(..)
| Destination::Tracks(..)
| Destination::Segments(..),
) )
| (ClipboardContent::Waypoints(_), Destination::Waypoints(..)) | (ClipboardContent::Waypoints(_), Destination::Waypoints(..))
); );
let empty = match content { let empty = match content {
ClipboardContent::Files(files) => files.is_empty(), ClipboardContent::Files(files) => files.is_empty(),
ClipboardContent::Routes(routes) => routes.is_empty(),
ClipboardContent::Tracks(tracks) => tracks.is_empty(), ClipboardContent::Tracks(tracks) => tracks.is_empty(),
ClipboardContent::Segments(segments) => segments.is_empty(), ClipboardContent::Segments(segments) => segments.is_empty(),
ClipboardContent::Waypoints(waypoints) => waypoints.is_empty(), ClipboardContent::Waypoints(waypoints) => waypoints.is_empty(),
@@ -189,6 +208,7 @@ pub fn transfer(
.collect(); .collect();
Ok(add_files(state, files, place)) Ok(add_files(state, files, place))
} }
(ClipboardContent::Routes(routes), to) => transfer_routes(state, routes, copy, to),
(ClipboardContent::Tracks(tracks), to) => transfer_tracks(state, tracks, copy, to), (ClipboardContent::Tracks(tracks), to) => transfer_tracks(state, tracks, copy, to),
(ClipboardContent::Segments(segments), to) => transfer_segments(state, segments, copy, to), (ClipboardContent::Segments(segments), to) => transfer_segments(state, segments, copy, to),
(ClipboardContent::Waypoints(waypoints), Destination::Waypoints(file_id, place)) => { (ClipboardContent::Waypoints(waypoints), Destination::Waypoints(file_id, place)) => {
@@ -198,6 +218,89 @@ pub fn transfer(
} }
} }
/// Puts the routes among the routes of a file, at `place`, and gives the selection of them.
fn insert_routes(
state: &mut State,
file_id: FileId,
place: Place<RouteId>,
routes: Vec<Route>,
) -> Result<Selection, CommandError> {
let ids: HashSet<RouteId> = routes.iter().map(|rte| rte.id).collect();
let file = file_mut(state, file_id)?;
let existing: Vec<RouteId> = file.rte.iter().map(|rte| rte.id).collect();
let at = place.position(&existing);
file.rte.splice(at..at, routes);
Ok(Selection::Route {
file_id,
rte_ids: ids,
})
}
fn transfer_routes(
state: &mut State,
routes: &[ClipboardRoute],
copy: bool,
to: Destination,
) -> Result<Selection, CommandError> {
let routes: Vec<(Route, &str)> = routes
.iter()
.map(|clip| {
let route = if copy {
copy_route(&clip.route)
} else {
clip.route.clone()
};
(route, clip.file_name.as_str())
})
.collect();
match to {
Destination::Files(place) => {
let files = routes
.into_iter()
.map(|(route, file_name)| {
let name = route
.info
.name
.clone()
.unwrap_or_else(|| file_name.to_owned());
Rc::new(File {
rte: vec![route],
..new_file(&name, vec![])
})
})
.collect();
Ok(add_files(state, files, place))
}
Destination::Routes(file_id, place) => {
let routes = routes.into_iter().map(|(route, _)| route).collect();
insert_routes(state, file_id, place, routes)
}
Destination::Tracks(file_id, _) => {
let routes = routes.into_iter().map(|(route, _)| route).collect();
insert_routes(state, file_id, Place::End, routes)
}
_ => Err(CommandError::NothingToDo),
}
}
/// Puts the tracks among the tracks of a file, at `place`, and gives the selection of them.
fn insert_tracks(
state: &mut State,
file_id: FileId,
place: Place<TrackId>,
tracks: Vec<Track>,
) -> Result<Selection, CommandError> {
let ids: HashSet<TrackId> = tracks.iter().map(|trk| trk.id).collect();
let file = file_mut(state, file_id)?;
let existing: Vec<TrackId> = file.trk.iter().map(|trk| trk.id).collect();
let at = place.position(&existing);
file.trk.splice(at..at, tracks);
Ok(Selection::Track {
file_id,
trk_ids: ids,
})
}
fn transfer_tracks( fn transfer_tracks(
state: &mut State, state: &mut State,
tracks: &[ClipboardTrack], tracks: &[ClipboardTrack],
@@ -231,16 +334,12 @@ fn transfer_tracks(
Ok(add_files(state, files, place)) Ok(add_files(state, files, place))
} }
Destination::Tracks(file_id, place) => { Destination::Tracks(file_id, place) => {
let tracks: Vec<Track> = tracks.into_iter().map(|(track, _)| track).collect(); let tracks = tracks.into_iter().map(|(track, _)| track).collect();
let ids: HashSet<TrackId> = tracks.iter().map(|trk| trk.id).collect(); insert_tracks(state, file_id, place, tracks)
let file = file_mut(state, file_id)?; }
let existing: Vec<TrackId> = file.trk.iter().map(|trk| trk.id).collect(); Destination::Routes(file_id, _) => {
let at = place.position(&existing); let tracks = tracks.into_iter().map(|(track, _)| track).collect();
file.trk.splice(at..at, tracks); insert_tracks(state, file_id, Place::Index(0), tracks)
Ok(Selection::Track {
file_id,
trk_ids: ids,
})
} }
_ => Err(CommandError::NothingToDo), _ => Err(CommandError::NothingToDo),
} }
@@ -276,20 +375,22 @@ fn transfer_segments(
.collect(); .collect();
Ok(add_files(state, files, place)) Ok(add_files(state, files, place))
} }
Destination::Routes(file_id, place) => {
let routes = segments
.into_iter()
.map(|(seg, _)| Route {
seg,
..Default::default()
})
.collect();
insert_routes(state, file_id, place, routes)
}
Destination::Tracks(file_id, place) => { Destination::Tracks(file_id, place) => {
let tracks: Vec<Track> = segments let tracks = segments
.into_iter() .into_iter()
.map(|(segment, _)| own_track(segment)) .map(|(segment, _)| own_track(segment))
.collect(); .collect();
let ids = tracks.iter().map(|trk| trk.id).collect(); insert_tracks(state, file_id, place, tracks)
let file = file_mut(state, file_id)?;
let existing: Vec<TrackId> = file.trk.iter().map(|trk| trk.id).collect();
let at = place.position(&existing);
file.trk.splice(at..at, tracks);
Ok(Selection::Track {
file_id,
trk_ids: ids,
})
} }
Destination::Segments(file_id, trk_id, place) => { Destination::Segments(file_id, trk_id, place) => {
let segments: Vec<TrackSegment> = let segments: Vec<TrackSegment> =
+8 -2
View File
@@ -4,6 +4,8 @@ mod file;
#[cfg(test)] #[cfg(test)]
pub(crate) mod fixture; pub(crate) mod fixture;
mod pattern; mod pattern;
#[cfg(test)]
mod routes_tests;
mod tools; mod tools;
pub use apply::*; pub use apply::*;
@@ -31,13 +33,15 @@ pub enum Command<'a> {
Move(Move), Move(Move),
DeleteAll(DeleteAll), DeleteAll(DeleteAll),
DeleteWaypoint(DeleteWaypoint), DeleteWaypoint(DeleteWaypoint),
Convert(Convert),
Metadata(Metadata<'a>), Metadata(Metadata<'a>),
Style(Style<'a>), Style(Style<'a>),
NewRoute(NewRoute),
NewTrack(NewTrack), NewTrack(NewTrack),
NewTrackSegment(NewTrackSegment), NewTrackSegment(NewTrackSegment),
Reverse(Reverse), Reverse(Reverse),
RoundTrip(RoundTrip), RoundTrip(RoundTrip),
Route(Route<'a>), Reroute(Reroute<'a>),
NewWaypoint(NewWaypoint<'a>), NewWaypoint(NewWaypoint<'a>),
EditWaypoint(EditWaypoint<'a>), EditWaypoint(EditWaypoint<'a>),
MoveWaypoint(MoveWaypoint), MoveWaypoint(MoveWaypoint),
@@ -66,13 +70,15 @@ impl Apply for Command<'_> {
Command::Move(c) => c.apply(state), Command::Move(c) => c.apply(state),
Command::DeleteAll(c) => c.apply(state), Command::DeleteAll(c) => c.apply(state),
Command::DeleteWaypoint(c) => c.apply(state), Command::DeleteWaypoint(c) => c.apply(state),
Command::Convert(c) => c.apply(state),
Command::Metadata(c) => c.apply(state), Command::Metadata(c) => c.apply(state),
Command::Style(c) => c.apply(state), Command::Style(c) => c.apply(state),
Command::NewRoute(c) => c.apply(state),
Command::NewTrack(c) => c.apply(state), Command::NewTrack(c) => c.apply(state),
Command::NewTrackSegment(c) => c.apply(state), Command::NewTrackSegment(c) => c.apply(state),
Command::Reverse(c) => c.apply(state), Command::Reverse(c) => c.apply(state),
Command::RoundTrip(c) => c.apply(state), Command::RoundTrip(c) => c.apply(state),
Command::Route(c) => c.apply(state), Command::Reroute(c) => c.apply(state),
Command::NewWaypoint(c) => c.apply(state), Command::NewWaypoint(c) => c.apply(state),
Command::EditWaypoint(c) => c.apply(state), Command::EditWaypoint(c) => c.apply(state),
Command::MoveWaypoint(c) => c.apply(state), Command::MoveWaypoint(c) => c.apply(state),
+12 -2
View File
@@ -1,4 +1,4 @@
use crate::{File, Track, TrackSegment, Waypoint}; use crate::{File, Route, Track, TrackSegment, Waypoint};
// The trackpoints of a segment are shared chunks, so copying elements is cheap. // The trackpoints of a segment are shared chunks, so copying elements is cheap.
@@ -18,10 +18,20 @@ pub fn copy_track(track: &Track) -> Track {
} }
} }
/// A copy of the file and of its tracks and segments, with new ids. /// A copy of the route and of its segment, with new ids.
pub fn copy_route(route: &Route) -> Route {
Route {
id: Default::default(),
seg: copy_segment(&route.seg),
..route.clone()
}
}
/// A copy of the file and of its routes, tracks and segments, with new ids.
pub fn copy_file(file: &File) -> File { pub fn copy_file(file: &File) -> File {
File { File {
id: Default::default(), id: Default::default(),
rte: file.rte.iter().map(copy_route).collect(),
trk: file.trk.iter().map(copy_track).collect(), trk: file.trk.iter().map(copy_track).collect(),
..file.clone() ..file.clone()
} }
@@ -18,7 +18,7 @@ pub fn update_segments(
let file = files let file = files
.entry(location.file_id) .entry(location.file_id)
.or_insert_with(|| (*state.files[&location.file_id]).clone()); .or_insert_with(|| (*state.files[&location.file_id]).clone());
f(i, &mut file.trk[location.trk].trkseg[location.seg]); f(i, file.segment_mut(location.index));
} }
for (file_id, file) in files { for (file_id, file) in files {
state.files.insert(file_id, Rc::new(file)); state.files.insert(file_id, Rc::new(file));
@@ -31,6 +31,8 @@ mod tests {
use crate::engine::command::fixture::Fixture; use crate::engine::command::fixture::Fixture;
use crate::SegmentIndex;
use super::*; use super::*;
fn fixture() -> (Fixture, FileId) { fn fixture() -> (Fixture, FileId) {
@@ -49,13 +51,11 @@ mod tests {
let locations = [ let locations = [
SegmentLocation { SegmentLocation {
file_id: id, file_id: id,
trk: 1, index: SegmentIndex::Track { trk: 1, seg: 0 },
seg: 0,
}, },
SegmentLocation { SegmentLocation {
file_id: id, file_id: id,
trk: 0, index: SegmentIndex::Track { trk: 0, seg: 0 },
seg: 0,
}, },
]; ];
let mut calls = vec![]; let mut calls = vec![];
@@ -1,6 +1,6 @@
use std::rc::Rc; use std::rc::Rc;
use crate::{File, FileId, Files, Selection, State, Track, TrackSegment, Waypoint}; use crate::{File, FileId, Files, Route, Selection, State, Track, TrackSegment, Waypoint};
/// What an [`Editor`] hook did to the element it was given. /// What an [`Editor`] hook did to the element it was given.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -21,13 +21,19 @@ impl Edit {
/// Independent edit of each selected element, without needing the rest of the state. /// 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 /// A selected file, route, track or segment is passed to the hook of its own level. By default
/// file is forwarded to its tracks and a track to its segments, so an editor only overrides /// a file is forwarded to its routes and tracks, and a route or a track to its segments, so an
/// the level(s) it cares about (metadata: file and track, style: track, reverse: segment). /// editor only overrides the level(s) it cares about (metadata: file, route and track, style:
/// Waypoints are only edited when selected, never through their file. /// route and track, reverse: segment). Waypoints are only edited when selected, never through
/// their file.
pub trait Editor { pub trait Editor {
fn file(&mut self, file: &mut File) -> Edit { fn file(&mut self, file: &mut File) -> Edit {
edit_each(&mut file.trk, |trk| self.track(trk)) let routes = edit_each(&mut file.rte, |rte| self.route(rte));
routes.merge(edit_each(&mut file.trk, |trk| self.track(trk)))
}
fn route(&mut self, route: &mut Route) -> Edit {
edit_segment(self, &mut route.seg)
} }
fn track(&mut self, track: &mut Track) -> Edit { fn track(&mut self, track: &mut Track) -> Edit {
@@ -95,7 +101,7 @@ fn edit_waypoints<E: Editor + ?Sized>(
} }
} }
/// Applies the editor to every selected file, track or segment. /// Applies the editor to every selected file, route, track or segment.
pub fn update_selected<E: Editor>(state: &mut State, editor: &mut E) { pub fn update_selected<E: Editor>(state: &mut State, editor: &mut E) {
match &*state.selection { match &*state.selection {
Selection::File { file_ids } => { Selection::File { file_ids } => {
@@ -103,6 +109,15 @@ pub fn update_selected<E: Editor>(state: &mut State, editor: &mut E) {
update_file(state.files, *id, |file| editor.file(file)); update_file(state.files, *id, |file| editor.file(file));
} }
} }
Selection::Route { file_id, rte_ids } => {
update_file(state.files, *file_id, |file| {
edit_where(
&mut file.rte,
|rte| rte_ids.contains(&rte.id),
|rte| editor.route(rte),
)
});
}
Selection::Track { file_id, trk_ids } => { Selection::Track { file_id, trk_ids } => {
update_file(state.files, *file_id, |file| { update_file(state.files, *file_id, |file| {
edit_where( edit_where(
+684
View File
@@ -0,0 +1,684 @@
//! The commands on routes: they work as on tracks, a route being a track of a single segment.
use std::rc::Rc;
use crate::{
Apply, Clean, CleanType, Clipboard, ClipboardContent, CommandError, Convert, Crop, Delete,
Duplicate, Extract, File, FileId, LngLat, LngLatBounds, Merge, MergeType, Metadata, Move,
MoveTarget, Paste, Reverse, RouteId, SegmentIndex, Selection, Split, SplitType, Style,
engine::command::fixture::Fixture, parse,
};
/// A file with the 2 routes of `with_routes` and the 2 tracks (of 2 segments) of
/// `with_tracks_and_segments`, which is the only file and is in the order.
fn fixture() -> (Fixture, FileId) {
let read = |name: &str| {
let data = std::fs::read(format!("data/{name}.gpx")).unwrap();
parse(&data, &mut Default::default()).unwrap()
};
let mut file = read("with_tracks_and_segments");
file.rte = read("with_routes").rte;
assert_eq!((file.rte.len(), file.trk.len()), (2, 2));
let mut fx = Fixture::default();
let id = file.id;
fx.files.insert(id, Rc::new(file));
fx.order.0.push(id);
(fx, id)
}
fn file(fx: &Fixture, id: FileId) -> &File {
&fx.files[&id]
}
fn routes(fx: &Fixture, id: FileId, indices: &[usize]) -> Selection {
Selection::Route {
file_id: id,
rte_ids: indices.iter().map(|i| file(fx, id).rte[*i].id).collect(),
}
}
fn whole(id: FileId) -> Selection {
Selection::File {
file_ids: [id].into(),
}
}
fn lengths(file: &File) -> Vec<usize> {
file.segments().map(|segment| segment.len()).collect()
}
#[test]
fn test_the_segments_of_a_file_are_the_ones_of_its_routes_then_its_tracks() {
let (fx, id) = fixture();
let file = file(&fx, id);
let locations = whole(id).segment_locations(&fx.files, &fx.order.0);
let indices: Vec<SegmentIndex> = locations.iter().map(|l| l.index).collect();
assert_eq!(
indices,
[
SegmentIndex::Route(0),
SegmentIndex::Route(1),
SegmentIndex::Track { trk: 0, seg: 0 },
SegmentIndex::Track { trk: 0, seg: 1 },
SegmentIndex::Track { trk: 1, seg: 0 },
SegmentIndex::Track { trk: 1, seg: 1 },
]
);
// a selection of routes covers their segments only
let selected = routes(&fx, id, &[1]).segment_locations(&fx.files, &fx.order.0);
assert_eq!(selected.len(), 1);
assert_eq!(selected[0].segment(&fx.files).id, file.rte[1].seg.id);
}
#[test]
fn test_routes_are_selected_like_tracks() {
let (fx, id) = fixture();
let ids: Vec<RouteId> = file(&fx, id).rte.iter().map(|r| r.id).collect();
let mut selection = routes(&fx, id, &[0]);
selection.extend(routes(&fx, id, &[1]));
assert_eq!(selection, routes(&fx, id, &[0, 1]));
selection.toggle(routes(&fx, id, &[0]));
assert_eq!(selection, routes(&fx, id, &[1]));
// routes and tracks are not selected together
let track = Selection::Track {
file_id: id,
trk_ids: [file(&fx, id).trk[0].id].into(),
};
selection.extend(track.clone());
assert_eq!(selection, track);
assert_eq!(
routes(&fx, id, &[0]).all_at_level(&fx.files, &fx.order.0),
Some(routes(&fx, id, &[0, 1]))
);
assert_eq!(
routes(&fx, id, &[0]).neighbour(&fx.files, &fx.order.0, true),
Some(routes(&fx, id, &[1]))
);
assert_eq!(
routes(&fx, id, &[1]).neighbour(&fx.files, &fx.order.0, true),
None
);
assert_eq!(routes(&fx, id, &[1]).to_files(), whole(id));
let mut gone = Selection::Route {
file_id: id,
rte_ids: [ids[0], RouteId::default()].into(),
};
gone.retain_existing(&fx.files);
assert_eq!(gone, routes(&fx, id, &[0]));
}
#[test]
fn test_delete_and_duplicate_routes() {
let (mut fx, id) = fixture();
let kept = file(&fx, id).rte[1].id;
fx.selection = routes(&fx, id, &[0]);
Delete { whole_files: false }
.apply(&mut fx.state())
.unwrap();
assert_eq!(file(&fx, id).rte.len(), 1);
assert_eq!(file(&fx, id).rte[0].id, kept);
assert_eq!(file(&fx, id).trk.len(), 2);
assert_eq!(fx.selection, whole(id));
fx.selection = routes(&fx, id, &[0]);
Duplicate.apply(&mut fx.state()).unwrap();
let file = file(&fx, id);
assert_eq!(file.rte.len(), 2);
assert_ne!(file.rte[1].id, kept);
assert_ne!(file.rte[1].seg.id, file.rte[0].seg.id);
assert_eq!(file.rte[1].info, file.rte[0].info);
assert_eq!(
fx.selection,
Selection::Route {
file_id: id,
rte_ids: [file.rte[1].id].into()
}
);
}
#[test]
fn test_metadata_and_style_of_routes() {
let (mut fx, id) = fixture();
fx.selection = routes(&fx, id, &[1]);
Metadata {
name: "renamed",
desc: "about",
}
.apply(&mut fx.state())
.unwrap();
Style {
color: Some("00ff00"),
opacity: None,
width: Some(3.0),
}
.apply(&mut fx.state())
.unwrap();
let file = file(&fx, id);
let info = &file.rte[1].info;
assert_eq!(info.name.as_deref(), Some("renamed"));
assert_eq!(info.desc.as_deref(), Some("about"));
assert_eq!(info.color.as_deref(), Some("00ff00"));
assert_eq!(info.width, Some(3.0));
assert_ne!(file.rte[0].info.name.as_deref(), Some("renamed"));
// the style of a file is the one of its routes and tracks
fx.selection = whole(id);
Style {
color: Some("ff0000"),
opacity: None,
width: None,
}
.apply(&mut fx.state())
.unwrap();
let file = &fx.files[&id];
assert!(
file.rte
.iter()
.map(|r| &r.info)
.chain(file.trk.iter().map(|t| &t.info))
.all(|info| info.color.as_deref() == Some("ff0000"))
);
}
#[test]
fn test_copy_and_paste_routes() {
let (mut fx, id) = fixture();
let copied = file(&fx, id).rte[0].clone();
fx.clipboard = Clipboard::new(&routes(&fx, id, &[0]), &fx.files, &fx.order.0, false);
assert!(matches!(
fx.clipboard.as_ref().unwrap().content,
ClipboardContent::Routes(_)
));
// after the selected route
fx.selection = routes(&fx, id, &[1]);
Paste.apply(&mut fx.state()).unwrap();
let file = file(&fx, id);
assert_eq!(file.rte.len(), 3);
assert_ne!(file.rte[2].id, copied.id);
assert_eq!(file.rte[2].seg.len(), copied.seg.len());
assert!(
matches!(&fx.selection, Selection::Route { rte_ids, .. } if rte_ids.contains(&file.rte[2].id))
);
// nothing selected: a new file, named like the route
fx.selection = Selection::Empty;
Paste.apply(&mut fx.state()).unwrap();
assert_eq!(fx.order.0.len(), 2);
let new = &fx.files[&fx.order.0[1]];
assert_eq!(new.rte.len(), 1);
assert!(new.trk.is_empty());
assert_eq!(Some(&new.info.name), copied.info.name.as_ref());
// a track selected: among the routes of its file
fx.selection = Selection::Track {
file_id: id,
trk_ids: [fx.files[&id].trk[0].id].into(),
};
Paste.apply(&mut fx.state()).unwrap();
assert_eq!(fx.files[&id].rte.len(), 4);
// segments do not go in a route, but become routes next to it
let file = &fx.files[&id];
let track = &file.trk[0];
fx.clipboard = Clipboard::new(
&Selection::TrackSegment {
file_id: id,
trk_id: track.id,
trkseg_ids: [track.trkseg[1].id].into(),
},
&fx.files,
&fx.order.0,
false,
);
fx.selection = routes(&fx, id, &[0]);
Paste.apply(&mut fx.state()).unwrap();
let file = &fx.files[&id];
assert_eq!(file.rte.len(), 5);
assert_eq!(file.rte[1].seg.len(), file.trk[0].trkseg[1].len());
}
#[test]
fn test_cut_tracks_pasted_on_a_route_go_first_among_the_tracks() {
let (mut fx, id) = fixture();
let moved = file(&fx, id).trk[1].id;
fx.clipboard = Clipboard::new(
&Selection::Track {
file_id: id,
trk_ids: [moved].into(),
},
&fx.files,
&fx.order.0,
true,
);
fx.selection = routes(&fx, id, &[0]);
Paste.apply(&mut fx.state()).unwrap();
let file = file(&fx, id);
assert_eq!(file.trk.len(), 2);
assert_eq!(file.trk[0].id, moved);
assert_eq!(file.rte.len(), 2);
}
#[test]
fn test_move_routes() {
let (mut fx, id) = fixture();
let ids: Vec<RouteId> = file(&fx, id).rte.iter().map(|r| r.id).collect();
Move {
what: routes(&fx, id, &[1]),
to: MoveTarget::Routes {
file_id: id,
index: 0,
},
}
.apply(&mut fx.state())
.unwrap();
let order: Vec<RouteId> = file(&fx, id).rte.iter().map(|r| r.id).collect();
assert_eq!(order, [ids[1], ids[0]]);
// to the files: a file of its own
Move {
what: routes(&fx, id, &[0]),
to: MoveTarget::Files { index: 1 },
}
.apply(&mut fx.state())
.unwrap();
assert_eq!(file(&fx, id).rte.len(), 1);
let new = &fx.files[&fx.order.0[1]];
assert_eq!(new.rte[0].id, ids[1]);
// a segment moved among the routes becomes one
let track = &file(&fx, id).trk[0];
let segment = track.trkseg[0].id;
Move {
what: Selection::TrackSegment {
file_id: id,
trk_id: track.id,
trkseg_ids: [segment].into(),
},
to: MoveTarget::Routes {
file_id: id,
index: 1,
},
}
.apply(&mut fx.state())
.unwrap();
let file = file(&fx, id);
assert_eq!(file.rte[1].seg.id, segment);
assert_eq!(file.trk[0].trkseg.len(), 1);
}
#[test]
fn test_routes_and_tracks_moved_among_each_other_keep_their_kind() {
let (mut fx, id) = fixture();
let route = file(&fx, id).rte[0].id;
let track = file(&fx, id).trk[1].id;
// a route among the tracks: after the other routes, which come first
Move {
what: routes(&fx, id, &[0]),
to: MoveTarget::Tracks {
file_id: id,
index: 1,
},
}
.apply(&mut fx.state())
.unwrap();
let moved = file(&fx, id);
assert_eq!((moved.rte.len(), moved.trk.len()), (2, 2));
assert_eq!(moved.rte[1].id, route);
assert_eq!(
fx.selection,
Selection::Route {
file_id: id,
rte_ids: [route].into()
}
);
// a track among the routes: before the other tracks, which come after the routes
Move {
what: Selection::Track {
file_id: id,
trk_ids: [track].into(),
},
to: MoveTarget::Routes {
file_id: id,
index: 0,
},
}
.apply(&mut fx.state())
.unwrap();
let moved = file(&fx, id);
assert_eq!((moved.rte.len(), moved.trk.len()), (2, 2));
assert_eq!(moved.trk[0].id, track);
// and copied routes pasted on a track stay routes
fx.clipboard = Clipboard::new(&routes(&fx, id, &[0]), &fx.files, &fx.order.0, false);
fx.selection = Selection::Track {
file_id: id,
trk_ids: [track].into(),
};
Paste.apply(&mut fx.state()).unwrap();
let pasted = file(&fx, id);
assert_eq!((pasted.rte.len(), pasted.trk.len()), (3, 2));
}
#[test]
fn test_merge_routes() {
let (mut fx, id) = fixture();
let before = lengths(file(&fx, id));
let first = file(&fx, id).rte[0].id;
fx.selection = routes(&fx, id, &[0, 1]);
let merge = |type_| Merge {
type_,
remove_gaps: false,
keep_originals: false,
};
assert_eq!(
merge(MergeType::Group).apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
merge(MergeType::Connect).apply(&mut fx.state()).unwrap();
let file = file(&fx, id);
assert_eq!(file.rte.len(), 1);
assert_eq!(file.rte[0].id, first);
assert_eq!(file.rte[0].seg.len(), before[0] + before[1]);
assert_eq!(file.trk.len(), 2);
}
#[test]
fn test_merge_files_with_routes() {
let (mut fx, id) = fixture();
let other = File {
rte: file(&fx, id).rte.iter().map(crate::copy_route).collect(),
..File::default()
};
let other_id = other.id;
fx.files.insert(other_id, Rc::new(other));
fx.order.0.push(other_id);
let total: usize = lengths(file(&fx, id)).iter().sum::<usize>()
+ lengths(&fx.files[&other_id]).iter().sum::<usize>();
let mut grouped = Fixture {
files: fx.files.clone(),
order: fx.order.clone(),
selection: Selection::File {
file_ids: [id, other_id].into(),
},
..Default::default()
};
Merge {
type_: MergeType::Group,
remove_gaps: false,
keep_originals: false,
}
.apply(&mut grouped.state())
.unwrap();
let merged = &grouped.files[&id];
assert_eq!((merged.rte.len(), merged.trk.len()), (4, 2));
assert!(!grouped.files.contains_key(&other_id));
// connected in the first route, which comes first
fx.selection = Selection::File {
file_ids: [id, other_id].into(),
};
Merge {
type_: MergeType::Connect,
remove_gaps: false,
keep_originals: false,
}
.apply(&mut fx.state())
.unwrap();
let merged = &fx.files[&id];
assert_eq!((merged.rte.len(), merged.trk.len()), (1, 0));
assert_eq!(merged.rte[0].seg.len(), total);
}
#[test]
fn test_extract_a_file_with_routes() {
let (mut fx, id) = fixture();
let names: Vec<_> = file(&fx, id)
.rte
.iter()
.map(|r| r.info.name.clone().unwrap())
.collect();
fx.selection = whole(id);
Extract {
keep_originals: false,
}
.apply(&mut fx.state())
.unwrap();
// one file per route and one per track
assert_eq!(fx.order.0.len(), 4);
let files: Vec<&File> = fx.order.0.iter().map(|id| &*fx.files[id]).collect();
for (file, name) in files.iter().zip(&names) {
assert_eq!((file.rte.len(), file.trk.len()), (1, 0));
assert_eq!(&file.info.name, name);
}
for file in &files[2..] {
assert_eq!((file.rte.len(), file.trk.len()), (0, 2));
}
// a route has nothing to extract
let (mut fx, id) = fixture();
fx.selection = routes(&fx, id, &[0]);
assert_eq!(
Extract {
keep_originals: false
}
.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
#[test]
fn test_split_a_route() {
let (mut fx, id) = fixture();
let before = file(&fx, id).rte[1].clone();
fx.selection = routes(&fx, id, &[1]);
Split {
at: 10,
split_type: SplitType::Routes,
keep_originals: false,
}
.apply(&mut fx.state())
.unwrap();
// two routes, which share the trackpoint
let split = file(&fx, id);
assert_eq!(split.rte.len(), 3);
assert_eq!(split.rte[1].id, before.id);
assert_ne!(split.rte[2].id, before.id);
assert_eq!(split.rte[1].seg.len(), 11);
assert_eq!(split.rte[2].seg.len(), before.seg.len() - 10);
assert_eq!(split.rte[2].info, before.info);
// a route is not a track nor a segment, and the other way around
let (mut fx, id) = fixture();
fx.selection = whole(id);
let segment_start = lengths(file(&fx, id))[..2].iter().sum::<usize>() as u32;
for (at, split_type) in [
(10, SplitType::Tracks),
(10, SplitType::Segments),
(segment_start + 10, SplitType::Routes),
] {
assert_eq!(
Split {
at,
split_type,
keep_originals: false,
}
.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
assert_eq!(file(&fx, id).rte.len(), 2);
// into two files: the following routes and the tracks go to the second one
let (mut fx, id) = fixture();
let first_len = file(&fx, id).rte[0].seg.len();
fx.selection = whole(id);
Split {
at: 5,
split_type: SplitType::Files,
keep_originals: false,
}
.apply(&mut fx.state())
.unwrap();
assert_eq!(fx.order.0.len(), 2);
let (a, b) = (&fx.files[&fx.order.0[0]], &fx.files[&fx.order.0[1]]);
assert_eq!(a.id, id);
assert_eq!((a.rte.len(), a.trk.len()), (1, 0));
assert_eq!(a.rte[0].seg.len(), 6);
assert_eq!((b.rte.len(), b.trk.len()), (2, 2));
assert_eq!(b.rte[0].seg.len(), first_len - 5);
}
#[test]
fn test_crop_removes_the_routes_left_without_trackpoints() {
let (mut fx, id) = fixture();
let len = lengths(file(&fx, id));
fx.selection = whole(id);
// the second route only
let start = len[0] as u32;
Crop {
start,
end: start + len[1] as u32 - 1,
}
.apply(&mut fx.state())
.unwrap();
let file = file(&fx, id);
assert_eq!((file.rte.len(), file.trk.len()), (1, 0));
assert_eq!(file.rte[0].seg.len(), len[1]);
}
#[test]
fn test_clean_removes_the_routes_left_without_trackpoints() {
let (mut fx, id) = fixture();
fx.selection = routes(&fx, id, &[0]);
let everywhere = LngLatBounds {
sw: LngLat {
lng: -180.0,
lat: -90.0,
},
ne: LngLat {
lng: 180.0,
lat: 90.0,
},
};
Clean {
bounds: everywhere,
type_: CleanType::Inside,
trkpt: true,
wpt: false,
}
.apply(&mut fx.state())
.unwrap();
assert_eq!(file(&fx, id).rte.len(), 1);
assert_eq!(fx.selection, whole(id));
}
#[test]
fn test_reverse_a_route_and_the_routes_of_a_file() {
let (mut fx, id) = fixture();
let before = file(&fx, id).clone();
fx.selection = routes(&fx, id, &[0]);
Reverse.apply(&mut fx.state()).unwrap();
let route = &file(&fx, id).rte[0].seg;
let original = &before.rte[0].seg;
assert_eq!(
route[0].coordinates.lat,
original[original.len() - 1].coordinates.lat
);
assert_eq!(file(&fx, id).rte[1].seg.rev_id, before.rte[1].seg.rev_id);
let (mut fx, id) = fixture();
let before = file(&fx, id).clone();
fx.selection = whole(id);
Reverse.apply(&mut fx.state()).unwrap();
let file = file(&fx, id);
// the routes are in the opposite order, and still before the tracks
assert_eq!(file.rte[0].id, before.rte[1].id);
assert_eq!(file.trk[0].id, before.trk[1].id);
}
#[test]
fn test_convert_routes_to_tracks_and_back() {
let (mut fx, id) = fixture();
let before = file(&fx, id).clone();
// a route becomes a track, first among the tracks, with its segment and its information
fx.selection = routes(&fx, id, &[1]);
Convert.apply(&mut fx.state()).unwrap();
let converted = file(&fx, id);
assert_eq!((converted.rte.len(), converted.trk.len()), (1, 3));
assert_eq!(converted.trk[0].info, before.rte[1].info);
assert_eq!(converted.trk[0].trkseg.len(), 1);
assert_eq!(converted.trk[0].trkseg[0].id, before.rte[1].seg.id);
assert_eq!(
fx.selection,
Selection::Track {
file_id: id,
trk_ids: [converted.trk[0].id].into()
}
);
// and back: a route again, after the other routes
Convert.apply(&mut fx.state()).unwrap();
let back = file(&fx, id);
assert_eq!((back.rte.len(), back.trk.len()), (2, 2));
assert_eq!(back.rte[1].seg.id, before.rte[1].seg.id);
assert_eq!(back.rte[1].info, before.rte[1].info);
assert!(
matches!(&fx.selection, Selection::Route { rte_ids, .. } if rte_ids.contains(&back.rte[1].id))
);
}
#[test]
fn test_convert_a_track_to_one_route_per_segment() {
let (mut fx, id) = fixture();
let track = file(&fx, id).trk[0].clone();
assert_eq!(track.trkseg.len(), 2);
fx.selection = Selection::Track {
file_id: id,
trk_ids: [track.id].into(),
};
Convert.apply(&mut fx.state()).unwrap();
let converted = file(&fx, id);
assert_eq!((converted.rte.len(), converted.trk.len()), (4, 1));
for (route, segment) in converted.rte[2..].iter().zip(&track.trkseg) {
assert_eq!(route.seg.id, segment.id);
assert_eq!(route.info, track.info);
}
assert!(matches!(&fx.selection, Selection::Route { rte_ids, .. } if rte_ids.len() == 2));
}
#[test]
fn test_convert_segments_to_routes_keeps_their_track() {
let (mut fx, id) = fixture();
let track = file(&fx, id).trk[1].clone();
fx.selection = Selection::TrackSegment {
file_id: id,
trk_id: track.id,
trkseg_ids: [track.trkseg[0].id, track.trkseg[1].id].into(),
};
Convert.apply(&mut fx.state()).unwrap();
let converted = file(&fx, id);
assert_eq!((converted.rte.len(), converted.trk.len()), (4, 2));
assert!(converted.trk[1].trkseg.is_empty());
assert_eq!(converted.rte[2].seg.id, track.trkseg[0].id);
// nothing to convert otherwise
for selection in [
whole(id),
Selection::Empty,
Selection::Waypoints { file_id: id },
] {
fx.selection = selection;
assert_eq!(
Convert.apply(&mut fx.state()),
Err(CommandError::NothingToDo)
);
}
}
@@ -24,7 +24,7 @@ impl Apply for ChangeLoopStart {
let index = self.index as usize; let index = self.index as usize;
let target = find_target(state, index, index)?.ok_or(CommandError::NothingToDo)?; let target = find_target(state, index, index)?.ok_or(CommandError::NothingToDo)?;
let mut file = (*state.files[&target.location.file_id]).clone(); let mut file = (*state.files[&target.location.file_id]).clone();
let segment = &mut file.trk[target.location.trk].trkseg[target.location.seg]; let segment = file.segment_mut(target.location.index);
let (start, len) = (target.start, segment.len()); let (start, len) = (target.start, segment.len());
if start == 0 || start >= len { if start == 0 || start >= len {
// it already is the start, or it is not a trackpoint // it already is the start, or it is not a trackpoint
+43 -20
View File
@@ -13,8 +13,8 @@ pub enum CleanType {
} }
/// Removes the trackpoints and/or waypoints of the selection lying inside (or outside) a /// Removes the trackpoints and/or waypoints of the selection lying inside (or outside) a
/// rectangle. A selected file cleans its tracks and its waypoints, a selected track or segment /// rectangle. A selected file cleans its routes, its tracks and its waypoints, a selected route,
/// only trackpoints, selected waypoints only waypoints. /// track or segment only trackpoints, selected waypoints only waypoints.
#[derive(Debug)] #[derive(Debug)]
pub struct Clean { pub struct Clean {
pub bounds: LngLatBounds, pub bounds: LngLatBounds,
@@ -85,6 +85,11 @@ struct Cleaner<'a> {
impl Editor for Cleaner<'_> { impl Editor for Cleaner<'_> {
fn file(&mut self, file: &mut File) -> Edit { fn file(&mut self, file: &mut File) -> Edit {
let mut edit = Edit::Unchanged; let mut edit = Edit::Unchanged;
for rte in &mut file.rte {
if self.route(rte) == Edit::Changed {
edit = Edit::Changed;
}
}
for trk in &mut file.trk { for trk in &mut file.trk {
if self.track(trk) == Edit::Changed { if self.track(trk) == Edit::Changed {
edit = Edit::Changed; edit = Edit::Changed;
@@ -124,37 +129,28 @@ impl Editor for Cleaner<'_> {
} }
} }
/// Removes what the clean left empty: the emptied segments, the tracks that lost all their /// Removes what the clean left empty: the emptied segments, the routes of these segments and the
/// segments and the files that lost something and have nothing left. The selection falls back /// tracks that lost all their segments. A file is never removed, even if nothing is left in it: it stays, empty. The
/// to the closest element still existing. /// selection falls back to the closest element still existing.
fn prune(state: &mut State, emptied: &HashSet<TrackSegmentId>, wpt_files: &HashSet<FileId>) { fn prune(state: &mut State, emptied: &HashSet<TrackSegmentId>, wpt_files: &HashSet<FileId>) {
let ids: Vec<FileId> = state let ids: Vec<FileId> = state
.files .files
.iter() .iter()
.filter(|(id, file)| { .filter(|(id, file)| {
wpt_files.contains(id) wpt_files.contains(id) || file.segments().any(|seg| emptied.contains(&seg.id))
|| file
.trk
.iter()
.any(|trk| trk.trkseg.iter().any(|seg| emptied.contains(&seg.id)))
}) })
.map(|(id, _)| *id) .map(|(id, _)| *id)
.collect(); .collect();
for id in ids { for id in ids {
let mut file = (*state.files[&id]).clone(); let mut file = (*state.files[&id]).clone();
let lost_something = !emptied.is_empty() || wpt_files.contains(&id); file.rte.retain(|rte| !emptied.contains(&rte.seg.id));
file.trk.retain_mut(|trk| { file.trk.retain_mut(|trk| {
let len = trk.trkseg.len(); let len = trk.trkseg.len();
trk.trkseg.retain(|seg| !emptied.contains(&seg.id)); trk.trkseg.retain(|seg| !emptied.contains(&seg.id));
trk.trkseg.len() == len || !trk.trkseg.is_empty() trk.trkseg.len() == len || !trk.trkseg.is_empty()
}); });
if lost_something && file.trk.is_empty() && file.wpt.is_empty() {
state.files.remove(&id);
state.order.0.retain(|f| *f != id);
} else {
state.files.insert(id, Rc::new(file)); state.files.insert(id, Rc::new(file));
} }
}
*state.selection = fixed_selection(state); *state.selection = fixed_selection(state);
} }
@@ -173,6 +169,27 @@ fn fixed_selection(state: &State) -> Selection {
Selection::File { file_ids } Selection::File { file_ids }
} }
} }
Selection::Route { file_id, rte_ids } => {
let Some(file) = files.get(file_id) else {
return Selection::Empty;
};
let rte_ids: HashSet<_> = file
.rte
.iter()
.map(|rte| rte.id)
.filter(|id| rte_ids.contains(id))
.collect();
if rte_ids.is_empty() {
Selection::File {
file_ids: [*file_id].into(),
}
} else {
Selection::Route {
file_id: *file_id,
rte_ids,
}
}
}
Selection::Track { file_id, trk_ids } => { Selection::Track { file_id, trk_ids } => {
let Some(file) = files.get(file_id) else { let Some(file) = files.get(file_id) else {
return Selection::Empty; return Selection::Empty;
@@ -297,10 +314,16 @@ mod tests {
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
assert_eq!(count(&fx, id), 0); assert_eq!(count(&fx, id), 0);
// nothing is left of the file // nothing is left in the file, which stays and is still selected
assert!(!fx.files.contains_key(&id)); let file = &fx.files[&id];
assert!(fx.order.0.is_empty()); assert!(file.trk.is_empty() && file.wpt.is_empty());
assert_eq!(fx.selection, Selection::Empty); assert_eq!(fx.order.0, [id]);
assert_eq!(
fx.selection,
Selection::File {
file_ids: HashSet::from([id])
}
);
} }
#[test] #[test]
+9 -8
View File
@@ -11,9 +11,9 @@ use crate::{
/// Keeps the trackpoints `start` to `end` (both included) of the selection, which are counted /// Keeps the trackpoints `start` to `end` (both included) of the selection, which are counted
/// over its segments one after the other (as in the `RoutingBuffer`), and removes the others. /// over its segments one after the other (as in the `RoutingBuffer`), and removes the others.
/// ///
/// The segments that have no trackpoint left are removed. So are the tracks that have no segment /// The segments that have no trackpoint left are removed, and so are the routes of these segments.
/// left, if tracks or files are selected (not if only segments are), and the files that have no /// So are the tracks that have no segment left, if tracks or files are selected (not if only
/// trackpoint left, if files are selected. A range that goes past the last trackpoint stops at it. /// segments are), and the files that have no trackpoint left, if files are selected. A range that goes past the last trackpoint stops at it.
#[derive(Debug)] #[derive(Debug)]
pub struct Crop { pub struct Crop {
pub start: u32, pub start: u32,
@@ -43,8 +43,7 @@ impl Apply for Crop {
.segment_locations(state.files, &state.order.0) .segment_locations(state.files, &state.order.0)
.into_iter() .into_iter()
.map(|location| { .map(|location| {
let len = let len = location.segment(state.files).len();
state.files[&location.file_id].trk[location.trk].trkseg[location.seg].len();
let keep = (len > 0 && start < offset + len && end >= offset) let keep = (len > 0 && start < offset + len && end >= offset)
.then(|| (start.saturating_sub(offset), (end - offset).min(len - 1))); .then(|| (start.saturating_sub(offset), (end - offset).min(len - 1)));
offset += len; offset += len;
@@ -73,14 +72,14 @@ impl Apply for Crop {
let mut removed: HashSet<TrackSegmentId> = HashSet::new(); let mut removed: HashSet<TrackSegmentId> = HashSet::new();
let mut had_points: HashSet<FileId> = HashSet::new(); let mut had_points: HashSet<FileId> = HashSet::new();
for plan in &plans { for plan in &plans {
let SegmentLocation { file_id, trk, seg } = plan.location; let SegmentLocation { file_id, index } = plan.location;
let file = files let file = files
.entry(file_id) .entry(file_id)
.or_insert_with(|| (*state.files[&file_id]).clone()); .or_insert_with(|| (*state.files[&file_id]).clone());
if plan.len > 0 { if plan.len > 0 {
had_points.insert(file_id); had_points.insert(file_id);
} }
let segment = &mut file.trk[trk].trkseg[seg]; let segment = file.segment_mut(index);
match plan.keep { match plan.keep {
None => { None => {
removed.insert(segment.id); removed.insert(segment.id);
@@ -91,6 +90,8 @@ impl Apply for Crop {
let mut gone: Vec<FileId> = vec![]; let mut gone: Vec<FileId> = vec![];
for (file_id, file) in files.iter_mut() { for (file_id, file) in files.iter_mut() {
// a route has its segment, or is not there
file.rte.retain(|route| !removed.contains(&route.seg.id));
// the tracks that lost segments // the tracks that lost segments
let touched: Vec<bool> = file let touched: Vec<bool> = file
.trk .trk
@@ -108,7 +109,7 @@ impl Apply for Crop {
file.trk file.trk
.retain(|track| !(touched.next().unwrap() && track.trkseg.is_empty())); .retain(|track| !(touched.next().unwrap() && track.trkseg.is_empty()));
} }
if prune_files && file.trk.is_empty() && had_points.contains(file_id) { if prune_files && file.has_no_path() && had_points.contains(file_id) {
gone.push(*file_id); gone.push(*file_id);
} }
} }
+1 -3
View File
@@ -14,9 +14,7 @@ impl Apply for Elevation<'_> {
.segment_locations(state.files, &state.order.0); .segment_locations(state.files, &state.order.0);
let count: usize = locations let count: usize = locations
.iter() .iter()
.map(|location| { .map(|location| location.segment(state.files).len())
state.files[&location.file_id].trk[location.trk].trkseg[location.seg].len()
})
.sum(); .sum();
if count == 0 { if count == 0 {
return Err(CommandError::NothingToDo); return Err(CommandError::NothingToDo);
+36 -30
View File
@@ -1,22 +1,23 @@
use std::{collections::HashSet, rc::Rc}; use std::{collections::HashSet, rc::Rc};
use crate::{ use crate::{
Apply, CommandError, File, FileId, LngLat, LngLatBounds, Selection, State, Track, TrackId, Apply, CommandError, File, FileId, LngLat, LngLatBounds, Route, Selection, State, Track,
TrackSegment, Waypoint, Waypoints, copy_file, copy_waypoint, crossarc_lnglat, distance, TrackId, TrackSegment, Waypoint, Waypoints, copy_file, copy_waypoint, crossarc_lnglat,
distance,
}; };
/// Splits the selected elements that are made of several segments into elements that have one /// Splits the selected elements that are made of several segments into elements that have one
/// each: /// each:
/// ///
/// - files: a file with several tracks becomes one file per track, in which the track is split /// - files: a file with several routes and tracks becomes one file per route and per track, in
/// into one track per segment. A file with a single track becomes one file per segment. The new /// which the track is split into one track per segment. A file with a single track becomes one
/// files are in the place of the file, which goes, and are selected. They are named after the /// file per segment. The new files are in the place of the file, which goes, and are selected.
/// track, or the file, and numbered. The waypoints go to the files that have the segments /// They are named after the route or track, or the file, and numbered. The waypoints go to the files that have the segments
/// closest to them (to the first file if the file has no trackpoint). /// closest to them (to the first file if the file has no trackpoint).
/// - tracks: each selected track is replaced by one track per segment, which are selected. They /// - tracks: each selected track is replaced by one track per segment, which are selected. They
/// are named after the track and numbered. /// are named after the track and numbered.
/// ///
/// What has a single segment is left as it is. There is nothing to do if nothing was changed. /// What has a single segment is left as it is, which routes always are. There is nothing to do if nothing was changed.
/// ///
/// With `keep_originals`, the files that are extracted stay where they are, and the new files come /// With `keep_originals`, the files that are extracted stay where they are, and the new files come
/// right after each of them, and only the new files are selected. Extracting /// right after each of them, and only the new files are selected. Extracting
@@ -46,8 +47,7 @@ fn extract_files(
let mut extracted: Vec<(FileId, Vec<File>)> = vec![]; let mut extracted: Vec<(FileId, Vec<File>)> = vec![];
for id in state.order.0.iter().filter(|id| file_ids.contains(id)) { for id in state.order.0.iter().filter(|id| file_ids.contains(id)) {
let file = &state.files[id]; let file = &state.files[id];
let segments: usize = file.trk.iter().map(|track| track.trkseg.len()).sum(); if file.segments().count() > 1 {
if segments > 1 {
let mut files = extract_file(file); let mut files = extract_file(file);
if keep_originals { if keep_originals {
// the tracks and segments are the ones of the file, which stays: they need new ids // the tracks and segments are the ones of the file, which stays: they need new ids
@@ -95,31 +95,35 @@ fn extract_files(
/// The files that a file with several segments becomes. /// The files that a file with several segments becomes.
fn extract_file(file: &File) -> Vec<File> { fn extract_file(file: &File) -> Vec<File> {
// for each new file: its tracks and its name, and the segments that go with it // for each new file: its routes, its tracks and its name, and the segments that go with it
struct Part { struct Part {
routes: Vec<Route>,
tracks: Vec<Track>, tracks: Vec<Track>,
name: String, name: String,
} }
let (parts, owner): (Vec<Part>, Vec<usize>) = if file.trk.len() > 1 { let name = |name: &Option<String>, i: usize| {
// one file per track, the segments of a track follow it name.clone()
let parts = file .unwrap_or_else(|| format!("{} ({})", file.info.name, i + 1))
.trk };
.iter() let (parts, owner): (Vec<Part>, Vec<usize>) =
.enumerate() if file.rte.len() + file.trk.len() > 1 {
.map(|(i, track)| Part { // one file per route and per track, the segments of a track follow it
let routes = file.rte.iter().enumerate().map(|(i, route)| Part {
routes: vec![route.clone()],
tracks: vec![],
name: name(&route.info.name, i),
});
let tracks = file.trk.iter().enumerate().map(|(i, track)| Part {
routes: vec![],
tracks: split_track(track), tracks: split_track(track),
name: track name: name(&track.info.name, file.rte.len() + i),
.info });
.name let parts = routes.chain(tracks).collect();
.clone() let owner =
.unwrap_or_else(|| format!("{} ({})", file.info.name, i + 1)), (0..file.rte.len())
}) .chain(file.trk.iter().enumerate().flat_map(|(i, track)| {
.collect(); track.trkseg.iter().map(move |_| file.rte.len() + i)
let owner = file }))
.trk
.iter()
.enumerate()
.flat_map(|(i, track)| track.trkseg.iter().map(move |_| i))
.collect(); .collect();
(parts, owner) (parts, owner)
} else { } else {
@@ -135,6 +139,7 @@ fn extract_file(file: &File) -> Vec<File> {
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, segment)| Part { .map(|(i, segment)| Part {
routes: vec![],
tracks: vec![Track { tracks: vec![Track {
id: if i == 0 { track.id } else { Default::default() }, id: if i == 0 { track.id } else { Default::default() },
trkseg: vec![segment.clone()], trkseg: vec![segment.clone()],
@@ -148,7 +153,7 @@ fn extract_file(file: &File) -> Vec<File> {
}; };
// the waypoints go with the segments that are the closest to them // the waypoints go with the segments that are the closest to them
let segments: Vec<&TrackSegment> = file.trk.iter().flat_map(|track| &track.trkseg).collect(); let segments: Vec<&TrackSegment> = file.segments().collect();
let mut waypoints: Vec<Vec<Waypoint>> = vec![vec![]; parts.len()]; let mut waypoints: Vec<Vec<Waypoint>> = vec![vec![]; parts.len()];
for waypoint in file.wpt.iter() { for waypoint in file.wpt.iter() {
let mut owners: Vec<usize> = closest_segments(&segments, waypoint.coordinates) let mut owners: Vec<usize> = closest_segments(&segments, waypoint.coordinates)
@@ -176,6 +181,7 @@ fn extract_file(file: &File) -> Vec<File> {
.map(|(part, waypoints)| { .map(|(part, waypoints)| {
let mut extracted = File { let mut extracted = File {
id: Default::default(), id: Default::default(),
rte: part.routes,
trk: part.tracks, trk: part.tracks,
wpt: Waypoints::default(), wpt: Waypoints::default(),
..file.clone() ..file.clone()
@@ -39,7 +39,7 @@ impl Apply for InsertAnchor {
.selection .selection
.segment_locations(state.files, &state.order.0) .segment_locations(state.files, &state.order.0)
{ {
let segment = &state.files[&location.file_id].trk[location.trk].trkseg[location.seg]; let segment = location.segment(state.files);
let mut points = segment.iter(); let mut points = segment.iter();
let Some(mut previous) = points.next() else { let Some(mut previous) = points.next() else {
continue; continue;
@@ -81,7 +81,7 @@ impl Apply for InsertAnchor {
let closest = closest.ok_or(CommandError::NothingToDo)?; let closest = closest.ok_or(CommandError::NothingToDo)?;
let mut file = (*state.files[&closest.location.file_id]).clone(); let mut file = (*state.files[&closest.location.file_id]).clone();
let segment = &mut file.trk[closest.location.trk].trkseg[closest.location.seg]; let segment = file.segment_mut(closest.location.index);
let before = closest.before; let before = closest.before;
// on a trackpoint already, if it is closer than `SNAP_DISTANCE` // on a trackpoint already, if it is closer than `SNAP_DISTANCE`
let snapped = if segment.len() == 1 { let snapped = if segment.len() == 1 {
+59 -11
View File
@@ -1,8 +1,9 @@
use std::{collections::HashSet, rc::Rc}; use std::{collections::HashSet, rc::Rc};
use crate::{ use crate::{
Apply, CommandError, FileId, GlobalStatistics, Selection, State, Statistics, Track, TrackId, Apply, CommandError, FileId, GlobalStatistics, Route, RouteId, Selection, State, Statistics,
TrackSegment, TrackSegmentId, Waypoint, Waypoints, copy_track, replace_trackpoints, Track, TrackId, TrackSegment, TrackSegmentId, Waypoint, Waypoints, copy_route, copy_track,
replace_trackpoints,
}; };
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
@@ -16,13 +17,16 @@ pub enum MergeType {
/// Merges the selected elements into the first of them: /// Merges the selected elements into the first of them:
/// ///
/// - files: the other files are deleted. The tracks of all of them are in the first file, as /// - files: the other files are deleted. The routes and tracks of all of them are in the first
/// they are with `Group`, or all connected in the first track with `Connect`. The waypoints of /// file, as they are with `Group`, or all connected in the first of them (a route or a track,
/// the routes of a file coming before its tracks) with `Connect`. The waypoints of
/// all of them are in the first file too, except the ones that are the same. With /// all of them are in the first file too, except the ones that are the same. With
/// `keep_originals`, no file is deleted nor changed: the result is a new file (with the info of /// `keep_originals`, no file is deleted nor changed: the result is a new file (with the info of
/// the first one and new ids), which comes right after the last of the files and is selected. /// the first one and new ids), which comes right after the last of the files and is selected.
/// - tracks of a file: the first track gets their segments, as they are with `Group`, or all /// - tracks of a file: the first track gets their segments, as they are with `Group`, or all
/// connected in one segment with `Connect`. The other tracks are removed. /// connected in one segment with `Connect`. The other tracks are removed.
/// - routes of a file: the first route gets their trackpoints, all connected with `Connect`, and
/// the other routes are removed. A route has a single segment, so there is nothing to `Group`.
/// - segments of a track: they are all connected in a segment at the position of the first one /// - segments of a track: they are all connected in a segment at the position of the first one
/// with `Connect`. With `Group` they are put one after the other, at that position. /// with `Connect`. With `Group` they are put one after the other, at that position.
/// ///
@@ -44,6 +48,7 @@ impl Apply for Merge {
let selection = state.selection.clone(); let selection = state.selection.clone();
match &selection { match &selection {
Selection::File { file_ids } => self.merge_files(state, file_ids), Selection::File { file_ids } => self.merge_files(state, file_ids),
Selection::Route { file_id, rte_ids } => self.merge_routes(state, *file_id, rte_ids),
Selection::Track { file_id, trk_ids } => self.merge_tracks(state, *file_id, trk_ids), Selection::Track { file_id, trk_ids } => self.merge_tracks(state, *file_id, trk_ids),
Selection::TrackSegment { Selection::TrackSegment {
file_id, file_id,
@@ -77,21 +82,26 @@ impl Merge {
return Err(CommandError::NothingToDo); return Err(CommandError::NothingToDo);
}; };
let mut routes: Vec<Route> = vec![];
let mut tracks: Vec<Track> = vec![]; let mut tracks: Vec<Track> = vec![];
let mut segments: Vec<TrackSegment> = vec![];
let mut waypoints: Vec<Waypoint> = vec![]; let mut waypoints: Vec<Waypoint> = vec![];
for id in &ordered { for id in &ordered {
let file = &state.files[id]; let file = &state.files[id];
routes.extend(file.rte.iter().cloned());
tracks.extend(file.trk.iter().cloned()); tracks.extend(file.trk.iter().cloned());
segments.extend(file.segments().cloned());
for waypoint in file.wpt.iter() { for waypoint in file.wpt.iter() {
if !waypoints.iter().any(|kept| kept.same_content(waypoint)) { if !waypoints.iter().any(|kept| kept.same_content(waypoint)) {
waypoints.push(waypoint.clone()); waypoints.push(waypoint.clone());
} }
} }
} }
let segments: Vec<TrackSegment> = tracks // the first route or track, which holds the connected segment
.iter() let first_is_route = ordered.iter().find_map(|id| {
.flat_map(|track| track.trkseg.iter().cloned()) let file = &state.files[id];
.collect(); (!file.has_no_path()).then(|| !file.rte.is_empty())
});
match self.type_ { match self.type_ {
MergeType::Group if ordered.len() == 1 => return Err(CommandError::NothingToDo), MergeType::Group if ordered.len() == 1 => return Err(CommandError::NothingToDo),
@@ -99,24 +109,35 @@ impl Merge {
return Err(CommandError::NothingToDo); return Err(CommandError::NothingToDo);
} }
MergeType::Connect if !segments.is_empty() => { MergeType::Connect if !segments.is_empty() => {
// a single track, which is the first one, with the connected segment // a single route or track, which is the first one, with the connected segment
let merged = connect(&segments, segments[0].id, self.remove_gaps); let merged = connect(&segments, segments[0].id, self.remove_gaps);
if first_is_route == Some(true) {
let first = Route {
seg: merged,
..routes[0].clone()
};
(routes, tracks) = (vec![first], vec![]);
} else {
let first = Track { let first = Track {
trkseg: vec![merged], trkseg: vec![merged],
..tracks[0].clone() ..tracks[0].clone()
}; };
tracks = vec![first]; (routes, tracks) = (vec![], vec![first]);
}
} }
_ => {} _ => {}
} }
let mut target = (*state.files[&target_id]).clone(); let mut target = (*state.files[&target_id]).clone();
target.rte = routes;
target.trk = tracks; target.trk = tracks;
target.wpt = Waypoints::default(); target.wpt = Waypoints::default();
target.wpt.insert_at(0, waypoints); target.wpt.insert_at(0, waypoints);
if self.keep_originals { if self.keep_originals {
// a new file, whose tracks and segments do not share their ids with the originals // a new file, whose routes, tracks and segments do not share their ids with the
// originals
target.id = Default::default(); target.id = Default::default();
target.rte = target.rte.iter().map(copy_route).collect();
target.trk = target.trk.iter().map(copy_track).collect(); target.trk = target.trk.iter().map(copy_track).collect();
let at = state let at = state
.order .order
@@ -139,6 +160,33 @@ impl Merge {
Ok(()) Ok(())
} }
fn merge_routes(
&self,
state: &mut State,
file_id: FileId,
rte_ids: &HashSet<RouteId>,
) -> Result<(), CommandError> {
let file = state.files.get(&file_id).ok_or(CommandError::NothingToDo)?;
let selected: Vec<usize> = (0..file.rte.len())
.filter(|i| rte_ids.contains(&file.rte[*i].id))
.collect();
if selected.len() < 2 || self.type_ == MergeType::Group {
return Err(CommandError::NothingToDo);
}
let first = selected[0];
let segments: Vec<TrackSegment> =
selected.iter().map(|i| file.rte[*i].seg.clone()).collect();
let mut file = (**file).clone();
file.rte[first].seg = connect(&segments, segments[0].id, self.remove_gaps);
let mut index = 0;
file.rte.retain(|_| {
index += 1;
!selected[1..].contains(&(index - 1))
});
state.files.insert(file_id, Rc::new(file));
Ok(())
}
fn merge_tracks( fn merge_tracks(
&self, &self,
state: &mut State, state: &mut State,
@@ -58,7 +58,8 @@ impl Apply for NewWaypoint<'_> {
.filter(|id| file_ids.contains(id)) .filter(|id| file_ids.contains(id))
.copied() .copied()
.collect(), .collect(),
Selection::Track { file_id, .. } Selection::Route { file_id, .. }
| Selection::Track { file_id, .. }
| Selection::TrackSegment { file_id, .. } | Selection::TrackSegment { file_id, .. }
| Selection::Waypoints { file_id } | Selection::Waypoints { file_id }
| Selection::Waypoint { file_id, .. } => vec![*file_id], | Selection::Waypoint { file_id, .. } => vec![*file_id],
@@ -118,12 +119,14 @@ mod tests {
New { New {
name: "a", name: "a",
trackpoint: None, trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
New { New {
name: "b", name: "b",
trackpoint: None, trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
@@ -169,6 +172,7 @@ mod tests {
New { New {
name: "a", name: "a",
trackpoint: None, trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
@@ -196,6 +200,7 @@ mod tests {
New { New {
name, name,
trackpoint: None, trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
@@ -234,6 +239,7 @@ mod tests {
New { New {
name: "a", name: "a",
trackpoint: None, trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
@@ -250,6 +256,7 @@ mod tests {
New { New {
name: "a", name: "a",
trackpoint: None, trackpoint: None,
route: false,
} }
.apply(&mut fx.state()) .apply(&mut fx.state())
.unwrap(); .unwrap();
+1 -1
View File
@@ -23,7 +23,7 @@ impl Apply for Reduce {
let mut changed = vec![]; let mut changed = vec![];
let mut kept_points = vec![]; let mut kept_points = vec![];
for location in &locations { for location in &locations {
let segment = &state.files[&location.file_id].trk[location.trk].trkseg[location.seg]; let segment = location.segment(state.files);
let kept = reduce_indices(segment, self.tolerance); let kept = reduce_indices(segment, self.tolerance);
if kept.len() == segment.len() { if kept.len() == segment.len() {
continue; continue;
+21 -9
View File
@@ -1,14 +1,15 @@
use std::rc::Rc; use std::rc::Rc;
use crate::{ use crate::{
Apply, CommandError, File, FileId, Selection, State, TrackId, TrackSegmentId, end_time, Apply, CommandError, File, FileId, RouteId, Selection, State, TrackId, TrackSegmentId,
reverse_segment, reverse_segments, reverse_tracks, start_time, track_end_time, end_time, reverse_routes, reverse_segment, reverse_segments, reverse_tracks, start_time,
track_start_time, track_end_time, track_start_time,
}; };
/// Reverses the direction of the selected elements: the whole of each selected file (its tracks, /// Reverses the direction of the selected elements: the whole of each selected file (its routes,
/// the segments of its tracks and their trackpoints are in the opposite order), of each selected /// its tracks, the segments of its tracks and their trackpoints are in the opposite order; the
/// track (its segments and their trackpoints), or each selected segment on its own (its /// routes and the tracks are two wholes, as the routes stay before the tracks), of each selected
/// track (its segments and their trackpoints), or each selected route or segment on its own (its
/// trackpoints). The timestamps are mirrored, see [`reverse_segment`]. /// trackpoints). The timestamps are mirrored, see [`reverse_segment`].
#[derive(Debug)] #[derive(Debug)]
pub struct Reverse; pub struct Reverse;
@@ -20,9 +21,7 @@ impl Apply for Reverse {
.segment_locations(state.files, &state.order.0); .segment_locations(state.files, &state.order.0);
let points: usize = locations let points: usize = locations
.iter() .iter()
.map(|location| { .map(|location| location.segment(state.files).len())
state.files[&location.file_id].trk[location.trk].trkseg[location.seg].len()
})
.sum(); .sum();
if points <= 1 { if points <= 1 {
return Err(CommandError::NothingToDo); return Err(CommandError::NothingToDo);
@@ -40,12 +39,18 @@ impl Apply for Reverse {
Selection::File { file_ids } => { Selection::File { file_ids } => {
for file_id in file_ids { for file_id in file_ids {
edit(file_id, &|file| { edit(file_id, &|file| {
let end = file.rte.last().and_then(|rte| end_time(&rte.seg));
let start = file.rte.first().and_then(|rte| start_time(&rte.seg));
reverse_routes(&mut file.rte, end, start);
let end = file.trk.last().and_then(track_end_time); let end = file.trk.last().and_then(track_end_time);
let start = file.trk.first().and_then(track_start_time); let start = file.trk.first().and_then(track_start_time);
reverse_tracks(&mut file.trk, end, start); reverse_tracks(&mut file.trk, end, start);
}); });
} }
} }
Selection::Route { file_id, rte_ids } => edit(file_id, &|file| {
reverse_selected_routes(file, rte_ids);
}),
Selection::Track { file_id, trk_ids } => edit(file_id, &|file| { Selection::Track { file_id, trk_ids } => edit(file_id, &|file| {
reverse_selected_tracks(file, trk_ids); reverse_selected_tracks(file, trk_ids);
}), }),
@@ -66,6 +71,13 @@ impl Apply for Reverse {
} }
} }
fn reverse_selected_routes(file: &mut File, ids: &std::collections::HashSet<RouteId>) {
for route in file.rte.iter_mut().filter(|route| ids.contains(&route.id)) {
let (end, start) = (end_time(&route.seg), start_time(&route.seg));
reverse_segment(&mut route.seg, end, start);
}
}
fn reverse_selected_tracks(file: &mut File, ids: &std::collections::HashSet<TrackId>) { fn reverse_selected_tracks(file: &mut File, ids: &std::collections::HashSet<TrackId>) {
for track in file.trk.iter_mut().filter(|track| ids.contains(&track.id)) { for track in file.trk.iter_mut().filter(|track| ids.contains(&track.id)) {
let end = track.trkseg.last().and_then(end_time); let end = track.trkseg.last().and_then(end_time);
@@ -14,7 +14,7 @@ impl Apply for RoundTrip {
.selection .selection
.segment_locations(state.files, &state.order.0) .segment_locations(state.files, &state.order.0)
.into_iter() .into_iter()
.filter(|l| state.files[&l.file_id].trk[l.trk].trkseg[l.seg].len() >= 2) .filter(|l| l.segment(state.files).len() >= 2)
.collect(); .collect();
if locations.is_empty() { if locations.is_empty() {
return Err(CommandError::NothingToDo); return Err(CommandError::NothingToDo);
+17 -14
View File
@@ -1,11 +1,11 @@
use std::rc::Rc; use std::rc::Rc;
use crate::{ use crate::{
Apply, Categories, CommandError, FileId, SegmentLocation, Selection, State, Statistics, Track, Apply, Categories, CommandError, FileId, SegmentIndex, SegmentLocation, Selection, State,
TrackSegment, Trackpoint, distance, replace_trackpoints, Statistics, Track, TrackSegment, Trackpoint, distance, replace_trackpoints,
}; };
/// The values of an OSM attribute (surface, highway...) of the new trackpoints of a [`Route`], as /// The values of an OSM attribute (surface, highway...) of the new trackpoints of a [`Reroute`], as
/// the intervals of consecutive trackpoints that share one: `starts[i]` is the index of the first /// the intervals of consecutive trackpoints that share one: `starts[i]` is the index of the first
/// trackpoint of the interval `i`, and `values[i]` its value, 0 when unknown, else 1 + the index /// trackpoint of the interval `i`, and `values[i]` its value, 0 when unknown, else 1 + the index
/// of its name in `names`. No intervals means that the attribute is unknown everywhere. /// of its name in `names`. No intervals means that the attribute is unknown everywhere.
@@ -64,7 +64,7 @@ impl RouteCategory<'_> {
/// anchors anyway. The ones right before and after the range, between which the route goes, become /// anchors anyway. The ones right before and after the range, between which the route goes, become
/// shown at every zoom level as well. /// shown at every zoom level as well.
#[derive(Debug)] #[derive(Debug)]
pub struct Route<'a> { pub struct Reroute<'a> {
pub start: u32, pub start: u32,
pub end: u32, pub end: u32,
pub lng: &'a [f64], pub lng: &'a [f64],
@@ -89,7 +89,7 @@ pub(super) struct Target {
pub end: usize, pub end: usize,
} }
impl Apply for Route<'_> { impl Apply for Reroute<'_> {
fn apply(self, state: &mut State) -> Result<(), CommandError> { fn apply(self, state: &mut State) -> Result<(), CommandError> {
let (start, end) = (self.start as usize, self.end as usize); let (start, end) = (self.start as usize, self.end as usize);
let len = self.lng.len(); let len = self.lng.len();
@@ -152,14 +152,17 @@ impl Apply for Route<'_> {
} }
let seg = file.trk[trk].trkseg.len() - 1; let seg = file.trk[trk].trkseg.len() - 1;
Target { Target {
location: SegmentLocation { file_id, trk, seg }, location: SegmentLocation {
file_id,
index: SegmentIndex::Track { trk, seg },
},
start: 0, start: 0,
end: 0, end: 0,
} }
} }
}; };
let segment = &mut file.trk[target.location.trk].trkseg[target.location.seg]; let segment = file.segment_mut(target.location.index);
let new_len = points.len(); let new_len = points.len();
let (speed, start_time) = speed_and_start_time(segment, &target, &points); let (speed, start_time) = speed_and_start_time(segment, &target, &points);
let has_times = !segment.is_empty() && segment[0].time.is_some(); let has_times = !segment.is_empty() && segment[0].time.is_some();
@@ -208,7 +211,7 @@ pub(super) fn find_target(
let segments: Vec<(SegmentLocation, usize)> = locations let segments: Vec<(SegmentLocation, usize)> = locations
.into_iter() .into_iter()
.map(|location| { .map(|location| {
let segment = &state.files[&location.file_id].trk[location.trk].trkseg[location.seg]; let segment = location.segment(state.files);
(location, segment.len()) (location, segment.len())
}) })
.collect(); .collect();
@@ -382,8 +385,8 @@ mod tests {
lng: &'a [f64], lng: &'a [f64],
lat: &'a [f64], lat: &'a [f64],
ele: &'a [f64], ele: &'a [f64],
) -> Route<'a> { ) -> Reroute<'a> {
Route { Reroute {
start, start,
end, end,
lng, lng,
@@ -459,7 +462,7 @@ mod tests {
route(0, 0, &[1.0], &[], &[]).apply(&mut fx.state()), route(0, 0, &[1.0], &[], &[]).apply(&mut fx.state()),
route(0, len + 1, &[], &[], &[]).apply(&mut fx.state()), route(0, len + 1, &[], &[], &[]).apply(&mut fx.state()),
route(len + 1, len + 1, &[1.0], &[1.0], &[1.0]).apply(&mut fx.state()), route(len + 1, len + 1, &[1.0], &[1.0], &[1.0]).apply(&mut fx.state()),
Route { Reroute {
anchors: &[1], anchors: &[1],
..route(0, 0, &[1.0], &[1.0], &[1.0]) ..route(0, 0, &[1.0], &[1.0], &[1.0])
} }
@@ -589,7 +592,7 @@ mod tests {
// 5 new points replacing the 2 in the middle, two of them are anchors // 5 new points replacing the 2 in the middle, two of them are anchors
let (lng, lat, ele) = ([1.0; 5], [2.0; 5], [3.0; 5]); let (lng, lat, ele) = ([1.0; 5], [2.0; 5], [3.0; 5]);
Route { Reroute {
anchors: &[1, 3], anchors: &[1, 3],
..route(1, 3, &lng, &lat, &ele) ..route(1, 3, &lng, &lat, &ele)
} }
@@ -639,7 +642,7 @@ mod tests {
let names = ["asphalt".to_string(), "gravel".to_string()]; let names = ["asphalt".to_string(), "gravel".to_string()];
let (lng, lat, ele) = ([1.0; 4], [2.0; 4], [3.0; 4]); let (lng, lat, ele) = ([1.0; 4], [2.0; 4], [3.0; 4]);
// asphalt, asphalt, unknown, gravel // asphalt, asphalt, unknown, gravel
Route { Reroute {
surface: RouteCategory { surface: RouteCategory {
starts: &[0, 2, 3], starts: &[0, 2, 3],
values: &[1, 0, 2], values: &[1, 0, 2],
@@ -683,7 +686,7 @@ mod tests {
}, },
]; ];
for surface in broken { for surface in broken {
let result = Route { let result = Reroute {
surface, surface,
..route(0, 0, &lng, &lat, &ele) ..route(0, 0, &lng, &lat, &ele)
} }
+89 -75
View File
@@ -1,8 +1,8 @@
use std::rc::Rc; use std::rc::Rc;
use crate::{ use crate::{
Apply, CommandError, File, Selection, State, Track, TrackSegment, Waypoints, copy_file, Apply, CommandError, File, Route, SegmentIndex, Selection, State, Track, TrackSegment,
copy_segment, copy_track, copy_waypoint, Waypoints, copy_file, copy_route, copy_segment, copy_track, copy_waypoint,
}; };
use super::route::find_target; use super::route::find_target;
@@ -13,24 +13,27 @@ use super::route::find_target;
pub enum SplitType { pub enum SplitType {
/// The file becomes two files, which both have the waypoints (copies, in the second one). /// The file becomes two files, which both have the waypoints (copies, in the second one).
Files, Files,
/// The route becomes two routes.
Routes,
/// The track becomes two tracks. /// The track becomes two tracks.
Tracks, Tracks,
/// The segment becomes two segments. /// The segment of a track becomes two segments.
Segments, Segments,
} }
/// Cuts the file, the track or the segment of the trackpoint `at` of the selection (see /// Cuts the file, the route, the track or the segment of the trackpoint `at` of the selection (see
/// `RoutingBuffer`) in two, there: the trackpoint is the last one of the first part and the first /// `RoutingBuffer`) in two, there: the trackpoint is the last one of the first part and the first
/// one of the second part. /// one of the second part. Cutting a route needs the trackpoint to be in a route, and cutting a
/// track or a segment needs it to be in a track: there is nothing to do otherwise.
/// ///
/// The first part keeps the ids of what is cut, the second one has new ids, as the elements that /// The first part keeps the ids of what is cut, the second one has new ids, as the elements that
/// come after the trackpoint (the following segments of the track, the following tracks of the /// come after the trackpoint (the following segments of the track, the following routes and
/// file), which are moved to it. When the file is cut, the new file comes right after it. The /// tracks of the file, the routes coming before the tracks), which are moved to it. When the file
/// selection does not change. /// is cut, the new file comes right after it. The selection does not change.
/// ///
/// With `keep_originals`, a file that is cut (`SplitType::Files`) is left as it is: its two parts /// With `keep_originals`, a file that is cut (`SplitType::Files`) is left as it is: its two parts
/// are two new files, with new ids, which come right after it and are selected. Cutting a track /// are two new files, with new ids, which come right after it and are selected. Cutting a route, a
/// or a segment changes the file in place anyway, so the flag has no effect on them. /// track or a segment changes the file in place anyway, so the flag has no effect on them.
#[derive(Debug)] #[derive(Debug)]
pub struct Split { pub struct Split {
pub at: u32, pub at: u32,
@@ -43,104 +46,115 @@ impl Apply for Split {
let at = self.at as usize; let at = self.at as usize;
let target = find_target(state, at, at)?.ok_or(CommandError::NothingToDo)?; let target = find_target(state, at, at)?.ok_or(CommandError::NothingToDo)?;
let location = target.location; let location = target.location;
let (file_id, trk, seg, index) = let (file_id, index) = (location.file_id, target.start);
(location.file_id, location.trk, location.seg, target.start); let in_route = matches!(location.index, SegmentIndex::Route(_));
let fits = match self.split_type {
SplitType::Files => true,
SplitType::Routes => in_route,
SplitType::Tracks | SplitType::Segments => !in_route,
};
if !fits {
return Err(CommandError::NothingToDo);
}
let mut file = (*state.files[&file_id]).clone(); let mut file = (*state.files[&file_id]).clone();
if index >= file.trk[trk].trkseg[seg].len() { if index >= file.segment(location.index).len() {
return Err(CommandError::InvalidData( return Err(CommandError::InvalidData(
"trackpoint out of the segment".into(), "trackpoint out of the segment".into(),
)); ));
} }
// with `SplitType::Files`, what goes to the second file: what comes after the trackpoint
let mut second_file = File {
id: Default::default(),
rte: vec![],
trk: vec![],
wpt: Waypoints::default(),
..file.clone()
};
match location.index {
SegmentIndex::Route(rte) => {
let route = file.rte[rte].clone();
let first = Route {
seg: keep_up_to(&route.seg, index),
..route.clone()
};
let second = Route {
id: Default::default(),
seg: keep_from(&route.seg, index),
..route.clone()
};
if self.split_type == SplitType::Routes {
file.rte.splice(rte..=rte, [first, second]);
state.files.insert(file_id, Rc::new(file));
return Ok(());
}
// the following routes, and the tracks, which come after the routes
second_file.rte = [second]
.into_iter()
.chain(file.rte[rte + 1..].iter().map(copy_route))
.collect();
second_file.trk = file.trk.iter().map(copy_track).collect();
file.rte.truncate(rte);
file.rte.push(first);
file.trk.clear();
}
SegmentIndex::Track { trk, seg } => {
let track = file.trk[trk].clone(); let track = file.trk[trk].clone();
let segment = &track.trkseg[seg]; let segment = &track.trkseg[seg];
let (first, second) = (keep_up_to(segment, index), keep_from(segment, index)); let (first, second) = (keep_up_to(segment, index), keep_from(segment, index));
// the track is cut: what is before the segment, and its first part, then the second part if self.split_type == SplitType::Segments {
// and what is after it file.trk[trk].trkseg.splice(seg..=seg, [first, second]);
let split_track = || { state.files.insert(file_id, Rc::new(file));
return Ok(());
}
// the track is cut: what is before the segment, and its first part, then the
// second part and what is after it
let mut first_track = track.clone(); let mut first_track = track.clone();
first_track.trkseg.truncate(seg); first_track.trkseg.truncate(seg);
first_track.trkseg.push(first.clone()); first_track.trkseg.push(first);
let mut second_track = Track { let mut second_track = Track {
id: Default::default(), id: Default::default(),
trkseg: vec![second.clone()], trkseg: vec![second],
..track.clone() ..track.clone()
}; };
second_track second_track
.trkseg .trkseg
.extend(track.trkseg[seg + 1..].iter().map(copy_segment)); .extend(track.trkseg[seg + 1..].iter().map(copy_segment));
(first_track, second_track) if self.split_type == SplitType::Tracks {
};
match self.split_type {
SplitType::Segments => {
file.trk[trk].trkseg.splice(seg..=seg, [first, second]);
}
SplitType::Tracks => {
let (first_track, second_track) = split_track();
file.trk.splice(trk..=trk, [first_track, second_track]); file.trk.splice(trk..=trk, [first_track, second_track]);
state.files.insert(file_id, Rc::new(file));
return Ok(());
} }
SplitType::Files if self.keep_originals => { second_file.trk = [second_track]
let (first_track, second_track) = split_track(); .into_iter()
// the parts are new files: nothing is shared by id with the file, nor between them .chain(file.trk[trk + 1..].iter().map(copy_track))
let first_file = copy_file(&File { .collect();
trk: file.trk[..trk] file.trk.truncate(trk);
.iter() file.trk.push(first_track);
.cloned() }
.chain([first_track]) }
.collect(),
..file.clone() // the file is cut: both parts have the waypoints
});
let mut second_file = File {
id: Default::default(),
trk: vec![second_track],
wpt: Waypoints::default(),
..file.clone()
};
second_file
.trk
.extend(file.trk[trk + 1..].iter().map(copy_track));
second_file second_file
.wpt .wpt
.insert_at(0, file.wpt.iter().map(copy_waypoint).collect()); .insert_at(0, file.wpt.iter().map(copy_waypoint).collect());
let ids = [first_file.id, second_file.id];
let position = state.order.0.iter().position(|id| *id == file_id); let position = state.order.0.iter().position(|id| *id == file_id);
let at = position.map_or(state.order.0.len(), |p| p + 1); let at = position.map_or(state.order.0.len(), |p| p + 1);
if self.keep_originals {
// the parts are new files: nothing is shared by id with the file, nor between them
let first_file = copy_file(&file);
let ids = [first_file.id, second_file.id];
state.order.0.splice(at..at, ids); state.order.0.splice(at..at, ids);
state.files.insert(first_file.id, Rc::new(first_file)); state.files.insert(first_file.id, Rc::new(first_file));
state.files.insert(second_file.id, Rc::new(second_file)); state.files.insert(second_file.id, Rc::new(second_file));
*state.selection = Selection::File { *state.selection = Selection::File {
file_ids: ids.into(), file_ids: ids.into(),
}; };
return Ok(()); } else {
} state.order.0.insert(at, second_file.id);
SplitType::Files => {
let (first_track, second_track) = split_track();
let mut second_file = File {
id: Default::default(),
trk: vec![second_track],
wpt: Waypoints::default(),
..file.clone()
};
second_file
.trk
.extend(file.trk[trk + 1..].iter().map(copy_track));
second_file
.wpt
.insert_at(0, file.wpt.iter().map(copy_waypoint).collect());
file.trk.truncate(trk);
file.trk.push(first_track);
let position = state.order.0.iter().position(|id| *id == file_id);
state.order.0.insert(
position.map_or(state.order.0.len(), |p| p + 1),
second_file.id,
);
state.files.insert(second_file.id, Rc::new(second_file)); state.files.insert(second_file.id, Rc::new(second_file));
}
}
state.files.insert(file_id, Rc::new(file)); state.files.insert(file_id, Rc::new(file));
}
Ok(()) Ok(())
} }
} }
+2 -4
View File
@@ -45,10 +45,8 @@ impl Apply for Time {
let locations = state let locations = state
.selection .selection
.segment_locations(state.files, &state.order.0); .segment_locations(state.files, &state.order.0);
let segments: Vec<&TrackSegment> = locations let segments: Vec<&TrackSegment> =
.iter() locations.iter().map(|l| l.segment(state.files)).collect();
.map(|l| &state.files[&l.file_id].trk[l.trk].trkseg[l.seg])
.collect();
if segments.iter().all(|segment| segment.is_empty()) { if segments.iter().all(|segment| segment.is_empty()) {
return Err(CommandError::NothingToDo); return Err(CommandError::NothingToDo);
} }
@@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet};
use crate::{FileId, FileWaypointsRevisionId, Files, TrackSegmentId, TrackSegmentRevisionId}; use crate::{FileId, FileWaypointsRevisionId, Files, TrackSegmentId, TrackSegmentRevisionId};
/// Coordinates buffers, as flat `[lng, lat, lng, lat, ...]` arrays so that they can be handed /// Coordinates buffers, as flat `[lng, lat, lng, lat, ...]` arrays so that they can be handed
/// to the UI without per-point calls: one per track segment (its trackpoints) and one per file /// to the UI without per-point calls: one per segment of a route or track (its trackpoints) and one per file
/// (its waypoints). A buffer is only rebuilt when the revision of its segment (or the waypoints /// (its waypoints). A buffer is only rebuilt when the revision of its segment (or the waypoints
/// of its file) changes, so editing a segment does not touch the buffers of the others. /// of its file) changes, so editing a segment does not touch the buffers of the others.
#[derive(Debug, Default)] #[derive(Debug, Default)]
@@ -30,7 +30,7 @@ impl CoordinatesCache {
self.waypoints self.waypoints
.insert(file.id, (file.wpt.rev_id, coordinates)); .insert(file.id, (file.wpt.rev_id, coordinates));
} }
for seg in file.trk.iter().flat_map(|trk| &trk.trkseg) { for seg in file.segments() {
segments.insert(seg.id); segments.insert(seg.id);
if self if self
.segments .segments
+56 -25
View File
@@ -1,9 +1,10 @@
use crate::{ use crate::{
File, FileId, FileWaypointsRevisionId, TrackId, TrackSegmentId, TrackSegmentRevisionId, File, FileId, FileWaypointsRevisionId, RouteId, TrackId, TrackInfo, TrackSegment,
WaypointId, TrackSegmentId, TrackSegmentRevisionId, WaypointId,
}; };
/// What the UI needs to display a file: its name and the structure of its tracks and waypoints, /// What the UI needs to display a file: its name and the structure of its routes, tracks and
/// waypoints,
/// with the ids that reference every element. Coordinates are not part of it, they are read /// with the ids that reference every element. Coordinates are not part of it, they are read
/// from the buffers of the [`CoordinatesCache`](crate::CoordinatesCache) (the `i`-th waypoint /// from the buffers of the [`CoordinatesCache`](crate::CoordinatesCache) (the `i`-th waypoint
/// of a file is the `i`-th coordinates pair of its waypoint buffer, same for the trackpoints of /// of a file is the `i`-th coordinates pair of its waypoint buffer, same for the trackpoints of
@@ -13,21 +14,47 @@ pub struct FileStructure {
pub id: FileId, pub id: FileId,
pub name: String, pub name: String,
pub desc: Option<String>, pub desc: Option<String>,
pub routes: Vec<RouteNode>,
pub tracks: Vec<TrackNode>, pub tracks: Vec<TrackNode>,
pub waypoints: Vec<WaypointNode>, pub waypoints: Vec<WaypointNode>,
/// Changes whenever the waypoints of the file (hence their buffer) change. /// Changes whenever the waypoints of the file (hence their buffer) change.
pub wpt_rev_id: FileWaypointsRevisionId, pub wpt_rev_id: FileWaypointsRevisionId,
} }
/// The name, description and style of a route or a track (the style only when the file defines
/// it).
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub struct TrackNode { pub struct PathInfo {
pub id: TrackId,
pub name: Option<String>, pub name: Option<String>,
pub desc: Option<String>, pub desc: Option<String>,
/// Style of the track, only when the file defines it.
pub color: Option<String>, pub color: Option<String>,
pub opacity: Option<f64>, pub opacity: Option<f64>,
pub width: Option<f64>, pub width: Option<f64>,
}
impl From<&TrackInfo> for PathInfo {
fn from(info: &TrackInfo) -> Self {
Self {
name: info.name.clone(),
desc: info.desc.clone(),
color: info.color.clone(),
opacity: info.opacity,
width: info.width,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct RouteNode {
pub id: RouteId,
pub info: PathInfo,
pub segment: SegmentNode,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TrackNode {
pub id: TrackId,
pub info: PathInfo,
pub segments: Vec<SegmentNode>, pub segments: Vec<SegmentNode>,
} }
@@ -46,31 +73,38 @@ pub struct WaypointNode {
pub sym: Option<String>, pub sym: Option<String>,
} }
impl From<&TrackSegment> for SegmentNode {
fn from(seg: &TrackSegment) -> Self {
Self {
id: seg.id,
rev_id: seg.rev_id,
len: seg.len(),
}
}
}
impl FileStructure { impl FileStructure {
pub fn new(file: &File) -> Self { pub fn new(file: &File) -> Self {
Self { Self {
id: file.id, id: file.id,
name: file.info.name.clone(), name: file.info.name.clone(),
desc: file.info.desc.clone(), desc: file.info.desc.clone(),
routes: file
.rte
.iter()
.map(|rte| RouteNode {
id: rte.id,
info: (&rte.info).into(),
segment: (&rte.seg).into(),
})
.collect(),
tracks: file tracks: file
.trk .trk
.iter() .iter()
.map(|trk| TrackNode { .map(|trk| TrackNode {
id: trk.id, id: trk.id,
name: trk.info.name.clone(), info: (&trk.info).into(),
desc: trk.info.desc.clone(), segments: trk.trkseg.iter().map(Into::into).collect(),
color: trk.info.color.clone(),
opacity: trk.info.opacity,
width: trk.info.width,
segments: trk
.trkseg
.iter()
.map(|seg| SegmentNode {
id: seg.id,
rev_id: seg.rev_id,
len: seg.len(),
})
.collect(),
}) })
.collect(), .collect(),
waypoints: file waypoints: file
@@ -112,11 +146,8 @@ mod tests {
); );
assert_eq!(node.tracks.len(), file.trk.len()); assert_eq!(node.tracks.len(), file.trk.len());
for (n, t) in node.tracks.iter().zip(&file.trk) { for (n, t) in node.tracks.iter().zip(&file.trk) {
assert_eq!((n.id, &n.name, &n.desc), (t.id, &t.info.name, &t.info.desc)); assert_eq!(n.id, t.id);
assert_eq!( assert_eq!(n.info, PathInfo::from(&t.info));
(&n.color, n.opacity, n.width),
(&t.info.color, t.info.opacity, t.info.width)
);
assert_eq!(n.segments.len(), t.trkseg.len()); assert_eq!(n.segments.len(), t.trkseg.len());
for (n, s) in n.segments.iter().zip(&t.trkseg) { for (n, s) in n.segments.iter().zip(&t.trkseg) {
assert_eq!((n.id, n.rev_id, n.len), (s.id, s.rev_id, s.len())); assert_eq!((n.id, n.rev_id, n.len), (s.id, s.rev_id, s.len()));
+1 -1
View File
@@ -39,7 +39,7 @@ impl RoutingBuffer {
.map(|files| selection.segment_locations(files, order)) .map(|files| selection.segment_locations(files, order))
.unwrap_or_default() .unwrap_or_default()
{ {
let segment = &files.unwrap()[&location.file_id].trk[location.trk].trkseg[location.seg]; let segment = location.segment(files.unwrap());
signature.push((segment.id, segment.rev_id)); signature.push((segment.id, segment.rev_id));
if segment.is_empty() { if segment.is_empty() {
continue; continue;
+81 -28
View File
@@ -1,4 +1,4 @@
use crate::{GlobalStatistics, Statistics, TrackSegment, sum_options}; use crate::{GlobalStatistics, SlopeSegment, Statistics, TrackSegment, sum_options};
/// Value of the timestamps of the trackpoints that have none. /// Value of the timestamps of the trackpoints that have none.
pub const NO_TIME: i64 = i64::MIN; pub const NO_TIME: i64 = i64::MIN;
@@ -27,8 +27,8 @@ pub struct StatisticsBuffer {
pub elevation_gain: Vec<f64>, pub elevation_gain: Vec<f64>,
pub elevation_loss: Vec<f64>, pub elevation_loss: Vec<f64>,
pub slope: Vec<f64>, pub slope: Vec<f64>,
pub slope_segment_slope: Vec<f64>, /// The smoothed elevation segments that the trackpoints belong to, see [`SlopeSegments`].
pub slope_segment_distance: Vec<f64>, pub slope_segments: SlopeSegments,
pub lng: Vec<f64>, pub lng: Vec<f64>,
pub lat: Vec<f64>, pub lat: Vec<f64>,
pub ele: Vec<f64>, pub ele: Vec<f64>,
@@ -62,6 +62,18 @@ pub struct Intervals {
} }
impl Intervals { impl Intervals {
/// The value of the trackpoint at `index` (the one of the last interval past the trackpoints),
/// 0 (unknown) when there are no trackpoints.
pub fn value_at(&self, index: usize) -> u8 {
match self
.starts
.partition_point(|start| *start as usize <= index)
{
0 => 0,
next => self.values[next - 1],
}
}
fn clear(&mut self) { fn clear(&mut self) {
self.starts.clear(); self.starts.clear();
self.values.clear(); self.values.clear();
@@ -76,6 +88,50 @@ impl Intervals {
} }
} }
/// The smoothed elevation segments of the selection, over which the slope is averaged, as the
/// intervals of trackpoints that belong to the same one: segment `i` starts at the trackpoint
/// `starts[i]` and ends where the next one starts, or at the last trackpoint. It has a `slope` (%)
/// and a length (`distance`, km). A segment never spans two segments of the selection. All are
/// empty when there are no trackpoints.
#[derive(Debug, Default, PartialEq)]
pub struct SlopeSegments {
pub starts: Vec<u32>,
pub slopes: Vec<f64>,
pub distances: Vec<f64>,
}
impl SlopeSegments {
/// The index of the segment of the trackpoint at `index` (the last one past the trackpoints),
/// `None` when there are no trackpoints.
pub fn at(&self, index: usize) -> Option<usize> {
let next = self
.starts
.partition_point(|start| *start as usize <= index);
next.checked_sub(1)
}
fn clear(&mut self) {
self.starts.clear();
self.slopes.clear();
self.distances.clear();
}
/// Adds the trackpoint at `index`, the indices being pushed in order: it starts a segment if
/// it is the first of a segment of the selection (`first`), or if its segment is not the one
/// of the previous trackpoint.
fn push(&mut self, index: usize, segment: &SlopeSegment, first: bool) {
// compared as bits, so that a NaN slope (no distance) is the same as the previous one
let same = |values: &[f64], value: f64| {
values.last().map(|last| last.to_bits()) == Some(value.to_bits())
};
if first || !same(&self.slopes, segment.slope) || !same(&self.distances, segment.distance) {
self.starts.push(index as u32);
self.slopes.push(segment.slope);
self.distances.push(segment.distance);
}
}
}
/// 0 for an unknown value, else the code plus one. The categories hold at most 255 values, so it /// 0 for an unknown value, else the code plus one. The categories hold at most 255 values, so it
/// fits. /// fits.
fn unknown_or_next(code: Option<u8>) -> u8 { fn unknown_or_next(code: Option<u8>) -> u8 {
@@ -117,8 +173,7 @@ impl StatisticsBuffer {
self.elevation_gain.clear(); self.elevation_gain.clear();
self.elevation_loss.clear(); self.elevation_loss.clear();
self.slope.clear(); self.slope.clear();
self.slope_segment_slope.clear(); self.slope_segments.clear();
self.slope_segment_distance.clear();
self.lng.clear(); self.lng.clear();
self.lat.clear(); self.lat.clear();
self.ele.clear(); self.ele.clear();
@@ -133,6 +188,7 @@ impl StatisticsBuffer {
self.mtb_scale.clear(); self.mtb_scale.clear();
for (segment, stats) in selected { for (segment, stats) in selected {
let segment_start = self.total_distance.len();
for (trkpt, trkpt_stats) in segment.iter().zip(stats.local.iter()) { for (trkpt, trkpt_stats) in segment.iter().zip(stats.local.iter()) {
let index = self.total_distance.len(); let index = self.total_distance.len();
let cumul_stats = &self.global; let cumul_stats = &self.global;
@@ -162,10 +218,8 @@ impl StatisticsBuffer {
self.elevation_loss self.elevation_loss
.push(cumul_stats.elevation_loss + trkpt_stats.elevation_loss); .push(cumul_stats.elevation_loss + trkpt_stats.elevation_loss);
self.slope.push(trkpt_stats.slope); self.slope.push(trkpt_stats.slope);
self.slope_segment_slope self.slope_segments
.push(trkpt_stats.slope_segment.slope); .push(index, &trkpt_stats.slope_segment, index == segment_start);
self.slope_segment_distance
.push(trkpt_stats.slope_segment.distance);
self.lng.push(trkpt.coordinates.lng); self.lng.push(trkpt.coordinates.lng);
self.lat.push(trkpt.coordinates.lat); self.lat.push(trkpt.coordinates.lat);
self.ele.push(trkpt.ele); self.ele.push(trkpt.ele);
@@ -229,25 +283,12 @@ mod tests {
(segment, stats) (segment, stats)
} }
/// The value of the trackpoint at `index`, 0 before the first interval.
fn value_at(intervals: &Intervals, index: usize) -> u8 {
match intervals
.starts
.partition_point(|start| *start as usize <= index)
{
0 => 0,
next => intervals.values[next - 1],
}
}
fn all_lengths(buffer: &StatisticsBuffer) -> Vec<usize> { fn all_lengths(buffer: &StatisticsBuffer) -> Vec<usize> {
vec![ vec![
buffer.total_distance.len(), buffer.total_distance.len(),
buffer.elevation_gain.len(), buffer.elevation_gain.len(),
buffer.elevation_loss.len(), buffer.elevation_loss.len(),
buffer.slope.len(), buffer.slope.len(),
buffer.slope_segment_slope.len(),
buffer.slope_segment_distance.len(),
buffer.lng.len(), buffer.lng.len(),
buffer.lat.len(), buffer.lat.len(),
buffer.ele.len(), buffer.ele.len(),
@@ -276,7 +317,19 @@ mod tests {
for (i, local) in s.local.iter().enumerate() { for (i, local) in s.local.iter().enumerate() {
assert_eq!(buffer.total_distance[i], local.total_distance); assert_eq!(buffer.total_distance[i], local.total_distance);
assert_eq!(buffer.slope[i], local.slope); assert_eq!(buffer.slope[i], local.slope);
let segment = buffer.slope_segments.at(i).unwrap();
assert_eq!(
buffer.slope_segments.slopes[segment].to_bits(),
local.slope_segment.slope.to_bits()
);
assert_eq!(
buffer.slope_segments.distances[segment],
local.slope_segment.distance
);
} }
// a segment per smoothed elevation segment, not per trackpoint
assert!(buffer.slope_segments.starts.len() < n);
assert_eq!(buffer.slope_segments.starts[0], 0);
// the trackpoints // the trackpoints
for (i, trkpt) in segment.iter().enumerate() { for (i, trkpt) in segment.iter().enumerate() {
assert_eq!(buffer.lng[i], trkpt.coordinates.lng); assert_eq!(buffer.lng[i], trkpt.coordinates.lng);
@@ -454,7 +507,7 @@ mod tests {
assert!(all_lengths(&buffer).iter().all(|len| *len == 5)); assert!(all_lengths(&buffer).iter().all(|len| *len == 5));
// and they can be looked up // and they can be looked up
let values: Vec<u8> = (0..6).map(|i| value_at(&buffer.surface, i)).collect(); let values: Vec<u8> = (0..6).map(|i| buffer.surface.value_at(i)).collect();
assert_eq!(values, [1, 1, 0, 2, 2, 2]); assert_eq!(values, [1, 1, 0, 2, 2, 2]);
} }
@@ -494,10 +547,10 @@ mod tests {
buffer.update(&[(&segment, &s)]); buffer.update(&[(&segment, &s)]);
let at = |i: usize| { let at = |i: usize| {
( (
value_at(&buffer.surface, i), buffer.surface.value_at(i),
value_at(&buffer.highway, i), buffer.highway.value_at(i),
value_at(&buffer.sac_scale, i), buffer.sac_scale.value_at(i),
value_at(&buffer.mtb_scale, i), buffer.mtb_scale.value_at(i),
) )
}; };
assert_eq!(at(0), (8, 255, 1, 4)); assert_eq!(at(0), (8, 255, 1, 4));
@@ -522,7 +575,7 @@ mod tests {
assert_eq!(buffer.highway, Intervals::default()); assert_eq!(buffer.highway, Intervals::default());
assert_eq!(buffer.sac_scale, Intervals::default()); assert_eq!(buffer.sac_scale, Intervals::default());
assert_eq!(buffer.mtb_scale, Intervals::default()); assert_eq!(buffer.mtb_scale, Intervals::default());
assert_eq!(value_at(&buffer.surface, 0), 0); assert_eq!(buffer.surface.value_at(0), 0);
} }
#[test] #[test]
+12 -5
View File
@@ -18,8 +18,7 @@ impl StatisticsCache {
let mut stale: HashSet<TrackSegmentRevisionId> = self.map.keys().copied().collect(); let mut stale: HashSet<TrackSegmentRevisionId> = self.map.keys().copied().collect();
if let Some(state) = state { if let Some(state) = state {
for file in state.values() { for file in state.values() {
for trk in file.trk.iter() { for trkseg in file.segments() {
for trkseg in trk.trkseg.iter() {
match self.map.entry(trkseg.rev_id) { match self.map.entry(trkseg.rev_id) {
Entry::Occupied(_) => { Entry::Occupied(_) => {
stale.remove(&trkseg.rev_id); stale.remove(&trkseg.rev_id);
@@ -31,7 +30,6 @@ impl StatisticsCache {
} }
} }
} }
}
for id in stale.iter() { for id in stale.iter() {
self.map.remove(id); self.map.remove(id);
} }
@@ -72,6 +70,15 @@ impl StatisticsCache {
} }
} }
} }
Selection::Route { file_id, rte_ids } => {
if let Some(file) = state.get(file_id) {
for rte in file.rte.iter() {
if rte_ids.contains(&rte.id) {
self.add_segment_stats(&rte.seg, &mut selected);
}
}
}
}
Selection::Track { file_id, trk_ids } => { Selection::Track { file_id, trk_ids } => {
if let Some(file) = state.get(file_id) { if let Some(file) = state.get(file_id) {
for trk in file.trk.iter() { for trk in file.trk.iter() {
@@ -129,8 +136,8 @@ impl StatisticsCache {
} }
fn add_file_stats<'a>(&'a self, file: &'a File, selected: &mut Vec<SelectedSegment<'a>>) { fn add_file_stats<'a>(&'a self, file: &'a File, selected: &mut Vec<SelectedSegment<'a>>) {
for trk in file.trk.iter() { for trkseg in file.segments() {
self.add_track_stats(trk, selected); self.add_segment_stats(trkseg, selected);
} }
} }
+4 -9
View File
@@ -82,7 +82,7 @@ impl<H: History> Engine<H> {
.find(|wpt| wpt.id == *id) .find(|wpt| wpt.id == *id)
} }
/// A trackpoint of a segment of a file. `None` if it does not exist. /// A trackpoint of a segment (of a route or a track) of a file. `None` if it does not exist.
pub fn trackpoint( pub fn trackpoint(
&self, &self,
file_id: &FileId, file_id: &FileId,
@@ -93,9 +93,7 @@ impl<H: History> Engine<H> {
.history .history
.current()? .current()?
.get(file_id)? .get(file_id)?
.trk .segments()
.iter()
.flat_map(|trk| &trk.trkseg)
.find(|seg| seg.id == *segment_id)?; .find(|seg| seg.id == *segment_id)?;
(index < segment.len()).then(|| &segment[index]) (index < segment.len()).then(|| &segment[index])
} }
@@ -109,11 +107,7 @@ impl<H: History> Engine<H> {
self.selection self.selection
.segment_locations(files, &self.order.0) .segment_locations(files, &self.order.0)
.iter() .iter()
.flat_map(|location| { .flat_map(|location| reduction_distances(location.segment(files)))
reduction_distances(
&files[&location.file_id].trk[location.trk].trkseg[location.seg],
)
})
.collect() .collect()
} }
@@ -248,6 +242,7 @@ impl<H: History> Engine<H> {
self.order.move_files(&file_ids, index); self.order.move_files(&file_ids, index);
None None
} }
Action::Rename { file_id, name } => self.history.rename(file_id, &name),
}; };
let changed = diff.is_some() let changed = diff.is_some()
|| self.selection != selection_before || self.selection != selection_before
+41 -10
View File
@@ -3,10 +3,18 @@ use std::rc::Rc;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
File, FileId, Files, Selection, Track, TrackId, TrackSegment, TrackSegmentId, Waypoint, File, FileId, Files, Route, RouteId, Selection, Track, TrackId, TrackSegment, TrackSegmentId,
WaypointId, Waypoint, WaypointId,
}; };
/// A route in the clipboard, with the name of the file it comes from (the name of the new file
/// when it is pasted on its own).
#[derive(Debug, Clone)]
pub struct ClipboardRoute {
pub route: Route,
pub file_name: String,
}
/// A track in the clipboard, with the name of the file it comes from (the name of the new file /// A track in the clipboard, with the name of the file it comes from (the name of the new file
/// when it is pasted on its own). /// when it is pasted on its own).
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -31,6 +39,7 @@ pub struct ClipboardSegment {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum ClipboardContent { pub enum ClipboardContent {
Files(Vec<Rc<File>>), Files(Vec<Rc<File>>),
Routes(Vec<ClipboardRoute>),
Tracks(Vec<ClipboardTrack>), Tracks(Vec<ClipboardTrack>),
Segments(Vec<ClipboardSegment>), Segments(Vec<ClipboardSegment>),
Waypoints(Vec<Waypoint>), Waypoints(Vec<Waypoint>),
@@ -84,6 +93,19 @@ impl Clipboard {
.filter_map(|id| files.get(id).cloned()) .filter_map(|id| files.get(id).cloned())
.collect(), .collect(),
), ),
Selection::Route { file_id, rte_ids } => {
let file = files.get(file_id)?;
ClipboardContent::Routes(
file.rte
.iter()
.filter(|rte| rte_ids.contains(&rte.id))
.map(|rte| ClipboardRoute {
route: rte.clone(),
file_name: file.info.name.clone(),
})
.collect(),
)
}
Selection::Track { file_id, trk_ids } => { Selection::Track { file_id, trk_ids } => {
let file = files.get(file_id)?; let file = files.get(file_id)?;
ClipboardContent::Tracks( ClipboardContent::Tracks(
@@ -144,21 +166,23 @@ impl Clipboard {
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
match &self.content { match &self.content {
ClipboardContent::Files(files) => files.is_empty(), ClipboardContent::Files(files) => files.is_empty(),
ClipboardContent::Routes(routes) => routes.is_empty(),
ClipboardContent::Tracks(tracks) => tracks.is_empty(), ClipboardContent::Tracks(tracks) => tracks.is_empty(),
ClipboardContent::Segments(segments) => segments.is_empty(), ClipboardContent::Segments(segments) => segments.is_empty(),
ClipboardContent::Waypoints(waypoints) => waypoints.is_empty(), ClipboardContent::Waypoints(waypoints) => waypoints.is_empty(),
} }
} }
/// Whether the content can be pasted when `target` is selected: files and tracks can be /// Whether the content can be pasted when `target` is selected: files, routes and tracks can
/// pasted anywhere above them in the file tree, and nothing selected stands for the list of /// be pasted anywhere above them in the file tree (routes and tracks are at the same level),
/// files. /// and nothing selected stands for the list of files.
/// ///
/// | copied | pasted when selected | /// | copied | pasted when selected |
/// |-----------|------------------------------------------| /// |-----------|-----------------------------------------------|
/// | files | nothing, a file | /// | files | nothing, a file |
/// | tracks | nothing, a file, a track | /// | routes | nothing, a file, a route, a track |
/// | segments | nothing, a file, a track, a segment | /// | tracks | nothing, a file, a route, a track |
/// | segments | nothing, a file, a route, a track, a segment |
/// | waypoints | a file, the waypoints, a waypoint | /// | waypoints | a file, the waypoints, a waypoint |
pub fn can_paste(&self, target: &Selection) -> bool { pub fn can_paste(&self, target: &Selection) -> bool {
use ClipboardContent as C; use ClipboardContent as C;
@@ -166,10 +190,13 @@ impl Clipboard {
matches!( matches!(
(&self.content, target), (&self.content, target),
(C::Files(_), Empty | File { .. }) (C::Files(_), Empty | File { .. })
| (C::Tracks(_), Empty | File { .. } | Track { .. }) | (
C::Routes(_) | C::Tracks(_),
Empty | File { .. } | Route { .. } | Track { .. }
)
| ( | (
C::Segments(_), C::Segments(_),
Empty | File { .. } | Track { .. } | TrackSegment { .. } Empty | File { .. } | Route { .. } | Track { .. } | TrackSegment { .. }
) )
| ( | (
C::Waypoints(_), C::Waypoints(_),
@@ -183,6 +210,7 @@ impl Clipboard {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClipboardIds { pub enum ClipboardIds {
Files(Vec<FileId>), Files(Vec<FileId>),
Routes(Vec<RouteId>),
Tracks(Vec<TrackId>), Tracks(Vec<TrackId>),
Segments(Vec<TrackSegmentId>), Segments(Vec<TrackSegmentId>),
Waypoints(Vec<WaypointId>), Waypoints(Vec<WaypointId>),
@@ -194,6 +222,9 @@ impl ClipboardContent {
ClipboardContent::Files(files) => { ClipboardContent::Files(files) => {
ClipboardIds::Files(files.iter().map(|file| file.id).collect()) ClipboardIds::Files(files.iter().map(|file| file.id).collect())
} }
ClipboardContent::Routes(routes) => {
ClipboardIds::Routes(routes.iter().map(|rte| rte.route.id).collect())
}
ClipboardContent::Tracks(tracks) => { ClipboardContent::Tracks(tracks) => {
ClipboardIds::Tracks(tracks.iter().map(|trk| trk.track.id).collect()) ClipboardIds::Tracks(tracks.iter().map(|trk| trk.track.id).collect())
} }
+17 -1
View File
@@ -1,6 +1,6 @@
use std::rc::Rc; use std::rc::Rc;
use super::{Diff, Files, History, diff_between}; use super::{Diff, Files, History, Renamer, diff_between};
use crate::{CommandError, FileId}; use crate::{CommandError, FileId};
/// One history for the whole workspace: each entry is a state of all the files. /// One history for the whole workspace: each entry is a state of all the files.
@@ -123,6 +123,22 @@ impl History for GlobalHistory {
None => !self.entries.is_empty(), None => !self.entries.is_empty(),
} }
} }
fn rename(&mut self, id: FileId, name: &str) -> Option<Diff> {
let before = self.current()?.get(&id)?.clone();
let mut renamer = Renamer::new(name);
for entry in self.entries.iter_mut() {
if let Some(file) = entry.get(&id) {
let renamed = renamer.rename(file);
entry.insert(id, renamed);
}
}
let after = self.current()?.get(&id)?;
(!Rc::ptr_eq(&before, after)).then(|| Diff {
modified: vec![id],
..Default::default()
})
}
} }
#[cfg(test)] #[cfg(test)]
+35
View File
@@ -57,6 +57,41 @@ pub trait History: Default + Debug {
/// Whether [`History::redo`] would do something for these `files`. /// Whether [`History::redo`] would do something for these `files`.
fn can_redo(&self, files: &[FileId]) -> bool; fn can_redo(&self, files: &[FileId]) -> bool;
/// Names a file `name` in all its states, as if it had always been called so: there is nothing
/// to undo. Says that the file changed, `None` if there is no such file or it is called so.
fn rename(&mut self, id: FileId, name: &str) -> Option<Diff>;
}
/// Names the states of a file: the ones that were shared stay shared (a state that several
/// entries have is renamed once), and the ones that are called so already are kept.
pub(super) struct Renamer<'a> {
name: &'a str,
renamed: HashMap<*const File, Rc<File>>,
}
impl<'a> Renamer<'a> {
pub(super) fn new(name: &'a str) -> Self {
Self {
name,
renamed: HashMap::new(),
}
}
pub(super) fn rename(&mut self, file: &Rc<File>) -> Rc<File> {
if file.info.name == self.name {
return file.clone();
}
let name = self.name;
self.renamed
.entry(Rc::as_ptr(file))
.or_insert_with(|| {
let mut renamed = (**file).clone();
renamed.info.name = name.to_owned();
Rc::new(renamed)
})
.clone()
}
} }
/// Which files were added, removed or modified by a change of the current files. /// Which files were added, removed or modified by a change of the current files.
+15 -1
View File
@@ -1,6 +1,6 @@
use std::rc::Rc; use std::rc::Rc;
use super::{Diff, Files, History, diff_between}; use super::{Diff, Files, History, Renamer, diff_between};
use crate::{CommandError, File, FileId}; use crate::{CommandError, File, FileId};
use std::collections::HashMap; use std::collections::HashMap;
@@ -139,6 +139,20 @@ impl History for PerFileHistory {
self.any(files, FileHistory::can_undo) self.any(files, FileHistory::can_undo)
} }
fn rename(&mut self, id: FileId, name: &str) -> Option<Diff> {
let history = self.stacks.get_mut(&id)?;
let mut renamer = Renamer::new(name);
for entry in history.entries.iter_mut() {
*entry = renamer.rename(entry);
}
let current = history.current().clone();
let before = self.current.insert(id, current.clone());
(!before.is_some_and(|before| Rc::ptr_eq(&before, &current))).then(|| Diff {
modified: vec![id],
..Default::default()
})
}
fn can_redo(&self, files: &[FileId]) -> bool { fn can_redo(&self, files: &[FileId]) -> bool {
self.any(files, FileHistory::can_redo) self.any(files, FileHistory::can_redo)
} }
+81 -27
View File
@@ -1,6 +1,8 @@
use std::collections::HashSet; use std::collections::HashSet;
use crate::{FileId, Files, TrackId, TrackSegmentId, WaypointId}; use crate::{
File, FileId, Files, RouteId, SegmentIndex, TrackId, TrackSegment, TrackSegmentId, WaypointId,
};
/// How a new selection combines with the current one. /// How a new selection combines with the current one.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)]
@@ -23,6 +25,10 @@ pub enum Selection {
File { File {
file_ids: HashSet<FileId>, file_ids: HashSet<FileId>,
}, },
Route {
file_id: FileId,
rte_ids: HashSet<RouteId>,
},
Track { Track {
file_id: FileId, file_id: FileId,
trk_ids: HashSet<TrackId>, trk_ids: HashSet<TrackId>,
@@ -41,12 +47,19 @@ pub enum Selection {
}, },
} }
/// Where a track segment is: its file, then its track and its own position in it. /// Where a segment is: its file, then its place in it (its route, or its track and its own
/// position in it).
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SegmentLocation { pub struct SegmentLocation {
pub file_id: FileId, pub file_id: FileId,
pub trk: usize, pub index: SegmentIndex,
pub seg: usize, }
impl SegmentLocation {
/// The segment, in `files`. Panics if it is not there.
pub fn segment<'a>(&self, files: &'a Files) -> &'a TrackSegment {
files[&self.file_id].segment(self.index)
}
} }
impl Selection { impl Selection {
@@ -89,23 +102,18 @@ impl Selection {
} }
impl Selection { impl Selection {
/// The track segments covered by the selection, in order: the segments of several selected /// The segments covered by the selection, in order: the segments of several selected files
/// files follow `order` (the files missing from it come last), then the tracks and segments /// follow `order` (the files missing from it come last), then the routes, tracks and segments
/// follow the file. Waypoints are not segments: selecting only waypoints covers none. /// follow the file (see [`File::segments`]). Waypoints are not segments: selecting only
/// waypoints covers none.
pub fn segment_locations(&self, files: &Files, order: &[FileId]) -> Vec<SegmentLocation> { pub fn segment_locations(&self, files: &Files, order: &[FileId]) -> Vec<SegmentLocation> {
let mut locations = vec![]; let mut locations = vec![];
let mut add = |file_id: FileId, let mut add = |file_id: FileId, filter: &dyn Fn(&File, SegmentIndex) -> bool| {
trk_filter: &dyn Fn(&crate::Track) -> bool,
seg_filter: &dyn Fn(&crate::TrackSegment) -> bool| {
let Some(file) = files.get(&file_id) else { let Some(file) = files.get(&file_id) else {
return; return;
}; };
for (trk, track) in file.trk.iter().enumerate().filter(|(_, t)| trk_filter(t)) { for index in file.segment_indices().filter(|index| filter(file, *index)) {
for (seg, segment) in track.trkseg.iter().enumerate() { locations.push(SegmentLocation { file_id, index });
if seg_filter(segment) {
locations.push(SegmentLocation { file_id, trk, seg });
}
}
} }
}; };
match self { match self {
@@ -116,25 +124,35 @@ impl Selection {
.filter(|id| file_ids.contains(id)) .filter(|id| file_ids.contains(id))
.chain(file_ids.iter().filter(|id| !order.contains(id))); .chain(file_ids.iter().filter(|id| !order.contains(id)));
for id in ordered { for id in ordered {
add(*id, &|_| true, &|_| true); add(*id, &|_, _| true);
} }
} }
Selection::Track { file_id, trk_ids } => { Selection::Route { file_id, rte_ids } => add(*file_id, &|file, index| match index {
add(*file_id, &|trk| trk_ids.contains(&trk.id), &|_| true) SegmentIndex::Route(rte) => rte_ids.contains(&file.rte[rte].id),
} SegmentIndex::Track { .. } => false,
}),
Selection::Track { file_id, trk_ids } => add(*file_id, &|file, index| match index {
SegmentIndex::Track { trk, .. } => trk_ids.contains(&file.trk[trk].id),
SegmentIndex::Route(_) => false,
}),
Selection::TrackSegment { Selection::TrackSegment {
file_id, file_id,
trk_id, trk_id,
trkseg_ids, trkseg_ids,
} => add(*file_id, &|trk| trk.id == *trk_id, &|seg| { } => add(*file_id, &|file, index| match index {
trkseg_ids.contains(&seg.id) SegmentIndex::Track { trk, seg } => {
let track = &file.trk[trk];
track.id == *trk_id && trkseg_ids.contains(&track.trkseg[seg].id)
}
SegmentIndex::Route(_) => false,
}), }),
} }
locations locations
} }
/// Drops what does not exist anymore (e.g. after an undo): files, and the tracks, segments /// Drops what does not exist anymore (e.g. after an undo): files, and the routes, tracks,
/// and waypoints of the files that remain. A selection left with nothing becomes empty. /// segments and waypoints of the files that remain. A selection left with nothing becomes
/// empty.
pub fn retain_existing(&mut self, files: &Files) { pub fn retain_existing(&mut self, files: &Files) {
let empty = match self { let empty = match self {
Selection::Empty => false, Selection::Empty => false,
@@ -142,6 +160,13 @@ impl Selection {
file_ids.retain(|id| files.contains_key(id)); file_ids.retain(|id| files.contains_key(id));
file_ids.is_empty() file_ids.is_empty()
} }
Selection::Route { file_id, rte_ids } => match files.get(file_id) {
Some(file) => {
rte_ids.retain(|id| file.rte.iter().any(|rte| rte.id == *id));
rte_ids.is_empty()
}
None => true,
},
Selection::Track { file_id, trk_ids } => match files.get(file_id) { Selection::Track { file_id, trk_ids } => match files.get(file_id) {
Some(file) => { Some(file) => {
trk_ids.retain(|id| file.trk.iter().any(|trk| trk.id == *id)); trk_ids.retain(|id| file.trk.iter().any(|trk| trk.id == *id));
@@ -185,6 +210,13 @@ impl Selection {
(Selection::File { file_ids }, Selection::File { file_ids: other }) => { (Selection::File { file_ids }, Selection::File { file_ids: other }) => {
file_ids.extend(other) file_ids.extend(other)
} }
(
Selection::Route { file_id, rte_ids },
Selection::Route {
file_id: other_file,
rte_ids: other,
},
) if *file_id == other_file => rte_ids.extend(other),
( (
Selection::Track { file_id, trk_ids }, Selection::Track { file_id, trk_ids },
Selection::Track { Selection::Track {
@@ -232,6 +264,16 @@ impl Selection {
toggle_ids(file_ids, other); toggle_ids(file_ids, other);
file_ids.is_empty() file_ids.is_empty()
} }
(
Selection::Route { file_id, rte_ids },
Selection::Route {
file_id: other_file,
rte_ids: other,
},
) if *file_id == other_file => {
toggle_ids(rte_ids, other);
rte_ids.is_empty()
}
( (
Selection::Track { file_id, trk_ids }, Selection::Track { file_id, trk_ids },
Selection::Track { Selection::Track {
@@ -325,7 +367,8 @@ impl Selection {
match self { match self {
Selection::Empty => Selection::Empty, Selection::Empty => Selection::Empty,
Selection::File { .. } => self.clone(), Selection::File { .. } => self.clone(),
Selection::Track { file_id, .. } Selection::Route { file_id, .. }
| Selection::Track { file_id, .. }
| Selection::TrackSegment { file_id, .. } | Selection::TrackSegment { file_id, .. }
| Selection::Waypoints { file_id } | Selection::Waypoints { file_id }
| Selection::Waypoint { file_id, .. } => Selection::File { | Selection::Waypoint { file_id, .. } => Selection::File {
@@ -335,7 +378,7 @@ impl Selection {
} }
/// Selects all the elements of the same kind as the selected ones, in the same place: all /// Selects all the elements of the same kind as the selected ones, in the same place: all
/// the files, all the tracks of the file, all the segments of the track, all the waypoints /// the files, all the routes (or tracks) of the file, all the segments of the track, all the waypoints
/// of the file. All the files when nothing is selected. `None` when there is nothing to /// of the file. All the files when nothing is selected. `None` when there is nothing to
/// select (the waypoints node, or a file that does not exist). /// select (the waypoints node, or a file that does not exist).
pub fn all_at_level(&self, files: &Files, order: &[FileId]) -> Option<Selection> { pub fn all_at_level(&self, files: &Files, order: &[FileId]) -> Option<Selection> {
@@ -343,6 +386,10 @@ impl Selection {
Selection::Empty | Selection::File { .. } => Some(Selection::File { Selection::Empty | Selection::File { .. } => Some(Selection::File {
file_ids: order.iter().copied().collect(), file_ids: order.iter().copied().collect(),
}), }),
Selection::Route { file_id, .. } => Some(Selection::Route {
file_id: *file_id,
rte_ids: files.get(file_id)?.rte.iter().map(|rte| rte.id).collect(),
}),
Selection::Track { file_id, .. } => Some(Selection::Track { Selection::Track { file_id, .. } => Some(Selection::Track {
file_id: *file_id, file_id: *file_id,
trk_ids: files.get(file_id)?.trk.iter().map(|trk| trk.id).collect(), trk_ids: files.get(file_id)?.trk.iter().map(|trk| trk.id).collect(),
@@ -371,7 +418,7 @@ impl Selection {
/// The element next to the selected ones, as a selection of that one element: the next one /// The element next to the selected ones, as a selection of that one element: the next one
/// (`down`) or the previous one among the elements of the same kind, in the same place. /// (`down`) or the previous one among the elements of the same kind, in the same place.
/// The tracks, segments and waypoints stop at their ends, the files wrap around and skip /// The routes, tracks, segments and waypoints stop at their ends, the files wrap around and skip
/// the ones that are already selected. `None` when there is no such element. /// the ones that are already selected. `None` when there is no such element.
pub fn neighbour(&self, files: &Files, order: &[FileId], down: bool) -> Option<Selection> { pub fn neighbour(&self, files: &Files, order: &[FileId], down: bool) -> Option<Selection> {
match self { match self {
@@ -393,6 +440,13 @@ impl Selection {
} }
} }
} }
Selection::Route { file_id, rte_ids } => {
let ids: Vec<RouteId> = files.get(file_id)?.rte.iter().map(|r| r.id).collect();
Some(Selection::Route {
file_id: *file_id,
rte_ids: [neighbour(&ids, rte_ids, down)?].into(),
})
}
Selection::Track { file_id, trk_ids } => { Selection::Track { file_id, trk_ids } => {
let ids: Vec<TrackId> = files.get(file_id)?.trk.iter().map(|t| t.id).collect(); let ids: Vec<TrackId> = files.get(file_id)?.trk.iter().map(|t| t.id).collect();
Some(Selection::Track { Some(Selection::Track {
+73
View File
@@ -87,6 +87,7 @@ fn new(engine: &mut TestEngine, name: &str) -> bool {
Command::New(New { Command::New(New {
name, name,
trackpoint: None, trackpoint: None,
route: false,
}), }),
) )
} }
@@ -182,6 +183,52 @@ fn test_metadata_renames_selected_file() {
assert_eq!(file.info.desc.as_deref(), Some("about")); assert_eq!(file.info.desc.as_deref(), Some("about"));
} }
#[test]
fn test_rename_names_every_state_of_the_file_and_is_not_undoable() {
let mut engine = TestEngine::default();
new(&mut engine, "first");
new(&mut engine, "other");
let id = engine.order()[0];
let other = engine.order()[1];
let other_file = engine.history.current().unwrap()[&other].clone();
engine.run(Action::Select {
selection: Selection::File {
file_ids: [id].into(),
},
mode: SelectMode::Replace,
});
assert!(edit(
&mut engine,
Command::Metadata(Metadata {
name: "edited",
desc: "",
})
));
assert!(engine.run(Action::Rename {
file_id: id,
name: "written".to_owned(),
}));
assert_eq!(engine.last_diff().unwrap().modified, vec![id]);
assert_eq!(engine.file_structure(&id).unwrap().name, "written");
// nothing to undo for it: the edits before are undone, and the file keeps its name
engine.run(Action::Undo);
engine.run(Action::Undo);
assert_eq!(engine.file_structure(&id).unwrap().name, "written");
engine.run(Action::Redo);
engine.run(Action::Redo);
assert_eq!(engine.file_structure(&id).unwrap().name, "written");
// the other file is still the same object, in every state
assert!(Rc::ptr_eq(
&engine.history.current().unwrap()[&other],
&other_file
));
// naming it as it is already called changes nothing
assert!(!engine.run(Action::Rename {
file_id: id,
name: "written".to_owned(),
}));
}
#[test] #[test]
fn test_metadata_without_selection_does_not_modify_files() { fn test_metadata_without_selection_does_not_modify_files() {
let mut engine = TestEngine::default(); let mut engine = TestEngine::default();
@@ -1443,6 +1490,7 @@ mod per_file_history {
.execute(Action::Edit(Command::New(New { .execute(Action::Edit(Command::New(New {
name, name,
trackpoint: None, trackpoint: None,
route: false,
}))) })))
.unwrap(); .unwrap();
*engine.order().last().unwrap() *engine.order().last().unwrap()
@@ -1469,6 +1517,31 @@ mod per_file_history {
engine.file_structure(&id).unwrap().name.clone() engine.file_structure(&id).unwrap().name.clone()
} }
#[test]
fn test_rename_names_every_state_of_the_file() {
let mut engine = Engine::<PerFileHistory>::default();
let id = new_file(&mut engine, "a");
select(&mut engine, &[id]);
rename(&mut engine, "b");
rename(&mut engine, "c");
let outcome = engine
.execute(Action::Rename {
file_id: id,
name: "written".to_owned(),
})
.unwrap();
assert_eq!(outcome.diff.unwrap().modified, vec![id]);
assert_eq!(name_of(&engine, id), "written");
// the edits are still there to undo, the name stays
engine.execute(Action::Undo).unwrap();
assert!(engine.can_undo());
engine.execute(Action::Undo).unwrap();
assert!(!engine.can_undo());
assert_eq!(name_of(&engine, id), "written");
engine.execute(Action::Redo).unwrap();
assert_eq!(name_of(&engine, id), "written");
}
#[test] #[test]
fn test_creating_a_file_is_not_undoable() { fn test_creating_a_file_is_not_undoable() {
let mut engine = Engine::<PerFileHistory>::default(); let mut engine = Engine::<PerFileHistory>::default();
+88 -36
View File
@@ -18,9 +18,9 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
File, FileId, FileInfo, Track, TrackId, TrackInfo, TrackSegment, TrackSegmentId, File, FileId, FileInfo, Route, RouteId, Track, TrackId, TrackInfo, TrackSegment,
TrackpointCategories, TrackpointChunk, TrackpointChunkId, Waypoint, WaypointChunk, TrackSegmentId, TrackpointCategories, TrackpointChunk, TrackpointChunkId, Waypoint,
WaypointChunkId, Waypoints, WaypointChunk, WaypointChunkId, Waypoints,
}; };
/// The version that is written. /// The version that is written.
@@ -54,10 +54,17 @@ fn parse<'a, T: Deserialize<'a>>(data: &'a [u8]) -> Result<T, DecodeError> {
/// A file without its trackpoints and waypoints, which are in chunks. /// A file without its trackpoints and waypoints, which are in chunks.
pub struct FileRecord { pub struct FileRecord {
info: FileInfo, info: FileInfo,
routes: Vec<RouteRecord>,
tracks: Vec<TrackRecord>, tracks: Vec<TrackRecord>,
waypoints: Vec<WaypointChunkId>, waypoints: Vec<WaypointChunkId>,
} }
struct RouteRecord {
id: RouteId,
info: TrackInfo,
segment: SegmentRecord,
}
struct TrackRecord { struct TrackRecord {
id: TrackId, id: TrackId,
info: TrackInfo, info: TrackInfo,
@@ -69,24 +76,35 @@ struct SegmentRecord {
chunks: Vec<TrackpointChunkId>, chunks: Vec<TrackpointChunkId>,
} }
impl From<v1::SegmentRecord1> for SegmentRecord {
fn from(segment: v1::SegmentRecord1) -> Self {
Self {
id: TrackSegmentId(segment.id),
chunks: segment.chunks.into_iter().map(TrackpointChunkId).collect(),
}
}
}
impl From<v1::FileRecord1> for FileRecord { impl From<v1::FileRecord1> for FileRecord {
fn from(record: v1::FileRecord1) -> Self { fn from(record: v1::FileRecord1) -> Self {
Self { Self {
info: record.info.into(), info: record.info.into(),
routes: record
.routes
.into_iter()
.map(|route| RouteRecord {
id: RouteId(route.id),
info: route.info.into(),
segment: route.segment.into(),
})
.collect(),
tracks: record tracks: record
.tracks .tracks
.into_iter() .into_iter()
.map(|track| TrackRecord { .map(|track| TrackRecord {
id: TrackId(track.id), id: TrackId(track.id),
info: track.info.into(), info: track.info.into(),
segments: track segments: track.segments.into_iter().map(Into::into).collect(),
.segments
.into_iter()
.map(|segment| SegmentRecord {
id: TrackSegmentId(segment.id),
chunks: segment.chunks.into_iter().map(TrackpointChunkId).collect(),
})
.collect(),
}) })
.collect(), .collect(),
waypoints: record.waypoints.into_iter().map(WaypointChunkId).collect(), waypoints: record.waypoints.into_iter().map(WaypointChunkId).collect(),
@@ -108,16 +126,27 @@ impl FileRecord {
info: self.info, info: self.info,
..Default::default() ..Default::default()
}; };
for track in self.tracks { let segment = |record: SegmentRecord| {
let mut trkseg = Vec::with_capacity(track.segments.len());
for segment in track.segments {
let mut built = TrackSegment::default(); let mut built = TrackSegment::default();
built.id = segment.id; built.id = record.id;
for chunk in &segment.chunks { for chunk in &record.chunks {
built.push_shared(trackpoint_chunks(chunk)?); built.push_shared(trackpoint_chunks(chunk)?);
} }
trkseg.push(built); Some(built)
};
for route in self.routes {
file.rte.push(Route {
id: route.id,
info: route.info,
seg: segment(route.segment)?,
});
} }
for track in self.tracks {
let trkseg = track
.segments
.into_iter()
.map(segment)
.collect::<Option<Vec<_>>>()?;
file.trk.push(Track { file.trk.push(Track {
id: track.id, id: track.id,
info: track.info, info: track.info,
@@ -292,6 +321,20 @@ mod tests {
}); });
let mut empty = TrackSegment::default(); let mut empty = TrackSegment::default();
empty.id = TrackSegmentId(id(6)); empty.id = TrackSegmentId(id(6));
// a route, whose segment shares the chunk of the track
let mut route_segment = TrackSegment::default();
route_segment.id = TrackSegmentId(id(10));
route_segment.push_shared(segment.chunks()[0].clone());
file.rte.push(Route {
id: RouteId(id(9)),
info: TrackInfo {
name: Some("route".into()),
type_: Some("Hiking".into()),
color: Some("00ff00".into()),
..Default::default()
},
seg: route_segment,
});
file.trk.push(Track { file.trk.push(Track {
id: TrackId(id(2)), id: TrackId(id(2)),
info: TrackInfo { info: TrackInfo {
@@ -352,9 +395,7 @@ mod tests {
HashMap<WaypointChunkId, Rc<WaypointChunk>>, HashMap<WaypointChunkId, Rc<WaypointChunk>>,
) { ) {
let trackpoints = file let trackpoints = file
.trk .segments()
.iter()
.flat_map(|track| &track.trkseg)
.flat_map(|segment| segment.chunks().iter().map(|c| (c.id, c.clone()))) .flat_map(|segment| segment.chunks().iter().map(|c| (c.id, c.clone())))
.collect(); .collect();
let waypoints = file let waypoints = file
@@ -373,20 +414,29 @@ mod tests {
}) })
} }
fn assert_same_file(a: &File, b: &File) { fn assert_same_segment(a: &TrackSegment, b: &TrackSegment) {
assert_eq!(a.id, b.id);
assert_eq!(a.info, b.info);
assert_eq!(a.trk.len(), b.trk.len());
for (a, b) in a.trk.iter().zip(&b.trk) {
assert_eq!((a.id, &a.info), (b.id, &b.info));
assert_eq!(a.trkseg.len(), b.trkseg.len());
for (a, b) in a.trkseg.iter().zip(&b.trkseg) {
assert_eq!(a.id, b.id); assert_eq!(a.id, b.id);
assert_eq!(a.len(), b.len()); assert_eq!(a.len(), b.len());
for (a, b) in a.chunks().iter().zip(b.chunks()) { for (a, b) in a.chunks().iter().zip(b.chunks()) {
assert!(Rc::ptr_eq(a, b), "the chunks are the ones that were given"); assert!(Rc::ptr_eq(a, b), "the chunks are the ones that were given");
} }
} }
fn assert_same_file(a: &File, b: &File) {
assert_eq!(a.id, b.id);
assert_eq!(a.info, b.info);
assert_eq!(a.rte.len(), b.rte.len());
for (a, b) in a.rte.iter().zip(&b.rte) {
assert_eq!((a.id, &a.info), (b.id, &b.info));
assert_same_segment(&a.seg, &b.seg);
}
assert_eq!(a.trk.len(), b.trk.len());
for (a, b) in a.trk.iter().zip(&b.trk) {
assert_eq!((a.id, &a.info), (b.id, &b.info));
assert_eq!(a.trkseg.len(), b.trkseg.len());
for (a, b) in a.trkseg.iter().zip(&b.trkseg) {
assert_same_segment(a, b);
}
} }
assert_eq!(a.wpt.len(), b.wpt.len()); assert_eq!(a.wpt.len(), b.wpt.len());
for (a, b) in a.wpt.chunks().iter().zip(b.wpt.chunks()) { for (a, b) in a.wpt.chunks().iter().zip(b.wpt.chunks()) {
@@ -425,7 +475,7 @@ mod tests {
.unwrap() .unwrap()
.build(file.id, &|_| None, &|_| None) .build(file.id, &|_| None, &|_| None)
.unwrap(); .unwrap();
assert!(built.trk.is_empty() && built.wpt.is_empty()); assert!(built.rte.is_empty() && built.trk.is_empty() && built.wpt.is_empty());
} }
/// The names of the categories of a trackpoint. /// The names of the categories of a trackpoint.
@@ -611,13 +661,15 @@ mod tests {
"70733a2f2f6578616d706c652e636f6d2f736f6d656f6e65021868747470733a2f2f6578616d706c652e636f", "70733a2f2f6578616d706c652e636f6d2f736f6d656f6e65021868747470733a2f2f6578616d706c652e636f",
"6d2f66696c65012074657874206f662068747470733a2f2f6578616d706c652e636f6d2f66696c651a687474", "6d2f66696c65012074657874206f662068747470733a2f2f6578616d706c652e636f6d2f66696c651a687474",
"70733a2f2f6578616d706c652e636f6d2f66696c652d32012274657874206f662068747470733a2f2f657861", "70733a2f2f6578616d706c652e636f6d2f66696c652d32012274657874206f662068747470733a2f2f657861",
"6d706c652e636f6d2f66696c652d320180a0abfef96201100000000000000000000000000000000201057472", "6d706c652e636f6d2f66696c652d320180a0abfef9620110000000000000000000000000000000090105726f",
"61636b0103636d740104646573630103737263021968747470733a2f2f6578616d706c652e636f6d2f747261", "75746500000000010648696b696e6701063030666630300000100000000000000000000000000000000a0110",
"636b012174657874206f662068747470733a2f2f6578616d706c652e636f6d2f747261636b1b68747470733a", "000000000000000000000000000000040110000000000000000000000000000000020105747261636b010363",
"2f2f6578616d706c652e636f6d2f747261636b2d32012374657874206f662068747470733a2f2f6578616d70", "6d740104646573630103737263021968747470733a2f2f6578616d706c652e636f6d2f747261636b01217465",
"6c652e636f6d2f747261636b2d3201074379636c696e67010666663030303001000000000000e03f01000000", "7874206f662068747470733a2f2f6578616d706c652e636f6d2f747261636b1b68747470733a2f2f6578616d",
"0000001840021000000000000000000000000000000003011000000000000000000000000000000004100000", "706c652e636f6d2f747261636b2d32012374657874206f662068747470733a2f2f6578616d706c652e636f6d",
"000000000000000000000000000600011000000000000000000000000000000005", "2f747261636b2d3201074379636c696e67010666663030303001000000000000e03f01000000000000184002",
"1000000000000000000000000000000003011000000000000000000000000000000004100000000000000000",
"000000000000000600011000000000000000000000000000000005",
); );
const TRACKPOINTS_V1: &str = concat!( const TRACKPOINTS_V1: &str = concat!(
"01020667726176656c0761737068616c74010470617468010668696b696e6701046d74623003000000000000", "01020667726176656c0761737068616c74010470617468010668696b696e6701046d74623003000000000000",
+29 -10
View File
@@ -8,8 +8,8 @@ use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
Author, Categories, File, FileInfo, Link, LngLat, TrackInfo, Trackpoint, TrackpointCategories, Author, Categories, File, FileInfo, Link, LngLat, TrackInfo, TrackSegment, Trackpoint,
Waypoint, WaypointId, TrackpointCategories, Waypoint, WaypointId,
}; };
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@@ -133,10 +133,18 @@ impl From<TrackInfo1> for TrackInfo {
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct FileRecord1 { pub struct FileRecord1 {
pub info: FileInfo1, pub info: FileInfo1,
pub routes: Vec<RouteRecord1>,
pub tracks: Vec<TrackRecord1>, pub tracks: Vec<TrackRecord1>,
pub waypoints: Vec<Uuid>, pub waypoints: Vec<Uuid>,
} }
#[derive(Serialize, Deserialize)]
pub struct RouteRecord1 {
pub id: Uuid,
pub info: TrackInfo1,
pub segment: SegmentRecord1,
}
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
pub struct TrackRecord1 { pub struct TrackRecord1 {
pub id: Uuid, pub id: Uuid,
@@ -150,24 +158,35 @@ pub struct SegmentRecord1 {
pub chunks: Vec<Uuid>, pub chunks: Vec<Uuid>,
} }
impl SegmentRecord1 {
fn new(segment: &TrackSegment) -> Self {
Self {
id: segment.id.0,
chunks: segment.chunks().iter().map(|chunk| chunk.id.0).collect(),
}
}
}
impl FileRecord1 { impl FileRecord1 {
pub fn new(file: &File) -> Self { pub fn new(file: &File) -> Self {
Self { Self {
info: (&file.info).into(), info: (&file.info).into(),
routes: file
.rte
.iter()
.map(|route| RouteRecord1 {
id: route.id.0,
info: (&route.info).into(),
segment: SegmentRecord1::new(&route.seg),
})
.collect(),
tracks: file tracks: file
.trk .trk
.iter() .iter()
.map(|track| TrackRecord1 { .map(|track| TrackRecord1 {
id: track.id.0, id: track.id.0,
info: (&track.info).into(), info: (&track.info).into(),
segments: track segments: track.trkseg.iter().map(SegmentRecord1::new).collect(),
.trkseg
.iter()
.map(|segment| SegmentRecord1 {
id: segment.id.0,
chunks: segment.chunks().iter().map(|chunk| chunk.id.0).collect(),
})
.collect(),
}) })
.collect(), .collect(),
waypoints: file.wpt.chunks().iter().map(|chunk| chunk.id.0).collect(), waypoints: file.wpt.chunks().iter().map(|chunk| chunk.id.0).collect(),
+1 -2
View File
@@ -217,8 +217,7 @@ impl Persister {
.files .files
.get(id) .get(id)
.is_none_or(|saved| !Rc::ptr_eq(saved, file)); .is_none_or(|saved| !Rc::ptr_eq(saved, file));
let segments = file.trk.iter().flat_map(|track| &track.trkseg); for chunk in file.segments().flat_map(|segment| segment.chunks()) {
for chunk in segments.flat_map(|segment| segment.chunks()) {
let key = ChunkKey::trackpoints(chunk.id.0); let key = ChunkKey::trackpoints(chunk.id.0);
if chunks.insert(key) && changed && !self.chunks.contains(&key) { if chunks.insert(key) && changed && !self.chunks.contains(&key) {
batch batch
+2
View File
@@ -25,4 +25,6 @@ notify = "8.2.0"
notify-debouncer-mini = "0.7.0" notify-debouncer-mini = "0.7.0"
tauri-plugin-dialog = "2.8.2" tauri-plugin-dialog = "2.8.2"
tauri-plugin-single-instance = "2.5.3" tauri-plugin-single-instance = "2.5.3"
tauri-plugin-opener = "2"
trash = "5"
-4
View File
@@ -34,7 +34,3 @@ pub fn optional(array: Option<Vec<u8>>) -> Bytes {
pub fn f64_bytes(values: &[f64]) -> Vec<u8> { pub fn f64_bytes(values: &[f64]) -> Vec<u8> {
values.iter().flat_map(|v| v.to_le_bytes()).collect() values.iter().flat_map(|v| v.to_le_bytes()).collect()
} }
pub fn i64_bytes(values: &[i64]) -> Vec<u8> {
values.iter().flat_map(|v| v.to_le_bytes()).collect()
}
+380 -64
View File
@@ -1,16 +1,22 @@
//! The files of the engine and the file system: picking files and folders, saving, and what the //! The files of the engine and the file system: picking files and folders, saving, closing, and
//! frontend shows of it (see `workspace`). //! what the frontend shows of it (see `workspace`).
//!
//! The commands that can lose the edits of a file (closing, opening another folder) take what to do
//! with the files that have some: the frontend asks the user, and the files that have edits stay
//! open if it does not say.
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::path::PathBuf; use std::path::{Path, PathBuf};
use gpx_engine::api; use gpx_engine::{ExportOptions, FileId, api};
use serde::Deserialize;
use tauri::{AppHandle, Runtime, State}; use tauri::{AppHandle, Runtime, State};
use tauri_plugin_dialog::{DialogExt, FilePath}; use tauri_plugin_dialog::{DialogExt, FilePath};
use uuid::Uuid; use uuid::Uuid;
use crate::commands::edit;
use crate::state::AppState; use crate::state::AppState;
use crate::workspace::{Unsaved, WorkspaceState}; use crate::workspace::{Entry, Unsaved, WorkspaceState, free_path};
fn paths(picked: Vec<FilePath>) -> Vec<PathBuf> { fn paths(picked: Vec<FilePath>) -> Vec<PathBuf> {
picked picked
@@ -19,6 +25,32 @@ fn paths(picked: Vec<FilePath>) -> Vec<PathBuf> {
.collect() .collect()
} }
/// Asks where to write a GPX file, proposing `file_name` in `folder`. `None` if the user cancelled.
fn ask_save_path<R: Runtime>(
app: &AppHandle<R>,
file_name: &str,
folder: Option<&Path>,
) -> Option<PathBuf> {
let mut dialog = app
.dialog()
.file()
.add_filter("GPX", &["gpx"])
.set_file_name(file_name);
if let Some(folder) = folder {
dialog = dialog.set_directory(folder);
}
dialog.blocking_save_file()?.into_path().ok()
}
/// Asks for a folder, starting in `folder`. `None` if the user cancelled.
fn ask_folder<R: Runtime>(app: &AppHandle<R>, folder: Option<&Path>) -> Option<PathBuf> {
let mut dialog = app.dialog().file();
if let Some(folder) = folder {
dialog = dialog.set_directory(folder);
}
dialog.blocking_pick_folder()?.into_path().ok()
}
#[tauri::command(async)] #[tauri::command(async)]
pub fn workspace_state(state: State<AppState>) -> WorkspaceState { pub fn workspace_state(state: State<AppState>) -> WorkspaceState {
state.core(|core| core.workspace_state()) state.core(|core| core.workspace_state())
@@ -35,17 +67,160 @@ pub fn open_files<R: Runtime>(app: AppHandle<R>, state: State<AppState>) -> api:
let Some(picked) = picked else { let Some(picked) = picked else {
return api::Outcome::failed_none(); return api::Outcome::failed_none();
}; };
let files = paths(picked); open_paths(state, paths(picked), None)
}
/// Opens files that are on the disk, like the ones of the tree of the open folder. They get selected.
/// With `preview`, a single file is open as a preview (a single click in the tree, see
/// `Workspace::open`); otherwise they are open for good, even the preview.
#[tauri::command(async)]
pub fn open_paths(
state: State<AppState>,
paths: Vec<PathBuf>,
preview: Option<bool>,
) -> api::Outcome {
state.core(move |core| { state.core(move |core| {
let outcome = core.workspace.open_files(&mut core.engine, &files, true); let outcome = core
core.keep_workspace(); .workspace
core.persist(); .open(&mut core.engine, &paths, preview.unwrap_or_default());
core.commit(None);
outcome outcome
}) })
} }
/// Opens the folder that the user picks: its GPX files are opened and it is watched. The folder /// The files are open for good: the preview among them is not a preview anymore (the user did
/// that was open is closed, `unsaved` says what to do with its files that have unsaved edits. /// something to it that the engine does not see, like hiding it).
#[tauri::command(async)]
pub fn keep_files(state: State<AppState>, file_ids: Vec<Uuid>) {
state.core(move |core| {
core.workspace.keep(&file_ids);
core.commit(None);
})
}
/// Writes the files among `ids` that are not the ones on the disk, asking where to put the ones
/// that have no place: the file dialog for a single one, a folder for several of them. Says whether
/// they were written, which is not the case if the user cancelled.
///
/// The files whose file on the disk was replaced are overwritten: the frontend asks before.
fn write_unsaved<R: Runtime>(
app: &AppHandle<R>,
state: &AppState,
ids: Vec<Uuid>,
) -> Result<bool, String> {
let (unlinked, start) = {
let ids = ids.clone();
state.core(move |core| {
(
core.workspace.unlinked(&core.engine, &ids),
core.workspace.folder.clone(),
)
})
};
let mut chosen = BTreeMap::new();
let mut folder = None;
match unlinked.as_slice() {
[] => {}
[(id, name)] => match ask_save_path(app, &format!("{name}.gpx"), start.as_deref()) {
Some(path) => {
chosen.insert(*id, path);
}
None => return Ok(false),
},
_ => match ask_folder(app, start.as_deref()) {
Some(path) => folder = Some(path),
None => return Ok(false),
},
}
state.core(move |core| {
let result = core
.workspace
.save(&mut core.engine, &ids, &chosen, folder.as_deref());
core.wrote(result)
})
}
/// Writes files to the disk, replacing what is there. The ones that are not on the disk yet go in
/// their folder, or where the user says. Says whether they were written (`false` if the user
/// cancelled).
#[tauri::command(async)]
pub fn save_files<R: Runtime>(
app: AppHandle<R>,
state: State<AppState>,
file_ids: Vec<Uuid>,
) -> Result<bool, String> {
write_unsaved(&app, &state, file_ids)
}
/// Writes a file where the user says, which is where the file is from then on. Says whether it was
/// written (`false` if the user cancelled).
#[tauri::command(async)]
pub fn save_file_as<R: Runtime>(
app: AppHandle<R>,
state: State<AppState>,
file_id: Uuid,
) -> Result<bool, String> {
let (name, current, folder) = state.core(move |core| {
let name = core
.engine
.file_structure(&FileId(file_id))
.map(|structure| structure.name.clone());
let status = core.workspace_state();
let file = status.files.get(&file_id.to_string());
let current = file.and_then(|file| file.path.clone().map(PathBuf::from));
let folder = file
.and_then(|file| file.folder.clone().map(PathBuf::from))
.or(status.folder.map(PathBuf::from));
(name, current, folder)
});
let Some(name) = name else {
return Ok(false);
};
// where the file is, or else where it would be written
let folder = current
.as_ref()
.and_then(|path| path.parent().map(Path::to_path_buf))
.or(folder);
let Some(path) = ask_save_path(&app, &format!("{name}.gpx"), folder.as_deref()) else {
return Ok(false);
};
state.core(move |core| {
let result = core.workspace.save_as(&mut core.engine, file_id, &path);
core.wrote(result)
})
}
/// Reads files again from the disk: the edits they have are lost (they are one undo step).
#[tauri::command(async)]
pub fn reload_files(state: State<AppState>, file_ids: Vec<Uuid>) -> Result<api::Outcome, String> {
state.core(move |core| {
let outcome = core.workspace.reload(&mut core.engine, &file_ids);
core.commit(None);
outcome
})
}
/// The files among `ids` that are not the ones on the disk, saved first if `unsaved` says so.
/// Says whether to go on, which is not the case if the user cancelled saving them.
fn settle<R: Runtime>(
app: &AppHandle<R>,
state: &AppState,
ids: Vec<Uuid>,
unsaved: Option<Unsaved>,
) -> Result<bool, String> {
if unsaved != Some(Unsaved::Save) {
return Ok(true);
}
let unsaved_ids = state.core(move |core| core.workspace.unsaved(&core.engine, &ids));
if unsaved_ids.is_empty() {
return Ok(true);
}
write_unsaved(app, state, unsaved_ids)
}
/// Opens the folder that the user picks, which is watched. Its files are not opened. The folder
/// that was open is closed with its files: `unsaved` says what to do with the ones that have
/// unsaved edits.
#[tauri::command(async)] #[tauri::command(async)]
pub fn open_folder<R: Runtime>( pub fn open_folder<R: Runtime>(
app: AppHandle<R>, app: AppHandle<R>,
@@ -60,80 +235,221 @@ pub fn open_folder<R: Runtime>(
else { else {
return Ok(api::Outcome::failed_none()); return Ok(api::Outcome::failed_none());
}; };
let all = state.core(|core| core.workspace.all(&core.engine));
if !settle(&app, &state, all, unsaved)? {
return Ok(api::Outcome::failed_none());
}
state.core(move |core| { state.core(move |core| {
let discard = unsaved.is_some();
let outcome = core let outcome = core
.workspace .workspace
.open_folder(&mut core.engine, &folder, unsaved); .open_folder(&mut core.engine, &folder, discard);
core.keep_workspace(); core.commit(None);
core.persist(); Ok(outcome)
core.watch();
core.announce(None);
outcome
}) })
} }
/// Closes the folder: it is not watched anymore, and its files are closed. `unsaved` says what to /// Closes the folder: it is not watched anymore, and the files are closed with it. `unsaved` says
/// do with the ones that have unsaved edits (they stay without it). /// what to do with the ones that have unsaved edits (they stay open without it).
#[tauri::command(async)] #[tauri::command(async)]
pub fn close_folder( pub fn close_folder<R: Runtime>(
app: AppHandle<R>,
state: State<AppState>, state: State<AppState>,
unsaved: Option<Unsaved>, unsaved: Option<Unsaved>,
) -> Result<api::Outcome, String> { ) -> Result<api::Outcome, String> {
let all = state.core(|core| core.workspace.all(&core.engine));
if !settle(&app, &state, all, unsaved)? {
return Ok(api::Outcome::failed_none());
}
state.core(move |core| { state.core(move |core| {
let outcome = core.workspace.close_folder(&mut core.engine, unsaved); let outcome = core
core.keep_workspace(); .workspace
core.persist(); .close_folder(&mut core.engine, unsaved.is_some());
core.watch(); core.commit(None);
core.announce(None); Ok(outcome)
outcome
}) })
} }
/// Writes files to the disk, replacing what is there. The ones that are not on the disk yet go in /// Closes files. `unsaved` says what to do with the ones that have unsaved edits: they are saved
/// the open folder, or where the user says if there is none. /// first, or the edits are lost (they stay open without it).
#[tauri::command(async)] #[tauri::command(async)]
pub fn save_files<R: Runtime>( pub fn close_files<R: Runtime>(
app: AppHandle<R>, app: AppHandle<R>,
state: State<AppState>, state: State<AppState>,
file_ids: Vec<Uuid>, file_ids: Vec<Uuid>,
) -> Result<(), String> { unsaved: Option<Unsaved>,
let (ids, unlinked, folder) = { ) -> Result<api::Outcome, String> {
let ids = file_ids.clone(); if !settle(&app, &state, file_ids.clone(), unsaved)? {
return Ok(api::Outcome::failed_none());
}
state.core(move |core| { state.core(move |core| {
( let outcome = core
ids.clone(), .workspace
core.workspace.unlinked(&core.engine, &ids), .close_files(&mut core.engine, &file_ids, unsaved.is_some());
core.workspace.folder.clone(), core.commit(None);
) Ok(outcome)
}) })
}; }
let mut chosen = BTreeMap::new();
// with a folder open, a new file goes in it: nobody is asked /// The folders and GPX files that are in a folder of the open folder, or in it (the root of the
let to_ask = if folder.is_some() { vec![] } else { unlinked }; /// tree when no path is given).
for (id, name) in to_ask { #[tauri::command(async)]
let mut dialog = app pub fn list_folder(state: State<AppState>, path: Option<PathBuf>) -> Result<Vec<Entry>, String> {
.dialog()
.file()
.add_filter("GPX", &["gpx"])
.set_file_name(format!("{name}.gpx"));
if let Some(folder) = &folder {
dialog = dialog.set_directory(folder);
}
match dialog
.blocking_save_file()
.and_then(|path| path.into_path().ok())
{
Some(path) => {
chosen.insert(id, path);
}
// cancelled: the others are not saved either
None => return Ok(()),
}
}
state.core(move |core| { state.core(move |core| {
let result = core.workspace.save(&core.engine, &ids, &chosen); let Some(path) = path.or_else(|| core.workspace.folder.clone()) else {
core.keep_workspace(); return Ok(vec![]);
core.announce(None); };
core.workspace.list_folder(&path)
})
}
/// What the files are exported with, see `ExportOptions`.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExportArguments {
time: bool,
hr: bool,
cad: bool,
atemp: bool,
power: bool,
osm: bool,
}
impl From<ExportArguments> for ExportOptions {
fn from(arguments: ExportArguments) -> Self {
Self {
time: arguments.time,
hr: arguments.hr,
cad: arguments.cad,
atemp: arguments.atemp,
power: arguments.power,
osm: arguments.osm,
}
}
}
/// Writes copies of files where the user says: a file dialog for a single one, a folder for
/// several. The files stay as they are. Says whether they were written (`false` if the user
/// cancelled).
#[tauri::command(async)]
pub fn export_files<R: Runtime>(
app: AppHandle<R>,
state: State<AppState>,
file_ids: Vec<Uuid>,
options: ExportArguments,
) -> Result<bool, String> {
let options = ExportOptions::from(options);
let files: Vec<(String, Vec<u8>)> = state.core(move |core| {
file_ids
.iter()
.filter_map(|id| {
let name = core.engine.file_structure(&FileId(*id))?.name.clone();
Some((name, api::export_file(&core.engine, *id, options)?))
})
.collect()
});
let write = |path: PathBuf, bytes: &[u8]| {
crate::store::write_atomically(&path, bytes)
.map_err(|error| format!("{}: {error}", path.display()))
};
match files.as_slice() {
[] => Ok(false),
[(name, bytes)] => {
let Some(path) = ask_save_path(&app, &format!("{name}.gpx"), None) else {
return Ok(false);
};
write(path, bytes).map(|()| true)
}
_ => {
let Some(folder) = ask_folder(&app, None) else {
return Ok(false);
};
let mut written: Vec<PathBuf> = vec![];
for (name, bytes) in &files {
let path = free_path(&folder, name, |path| written.iter().any(|w| w == path));
write(path.clone(), bytes)?;
written.push(path);
}
Ok(true)
}
}
}
/// Makes a folder in a folder of the open folder, called `name` (numbered if that is taken). Gives
/// its path.
#[tauri::command(async)]
pub fn create_folder(
state: State<AppState>,
parent: PathBuf,
name: String,
) -> Result<PathBuf, String> {
state.core(move |core| core.workspace.create_folder(&parent, &name))
}
/// Renames a file or a folder of the open folder. An open file is renamed like in its info (it is
/// selected), which can be undone; the rest is renamed on the disk, and the open files that are in
/// a folder that is renamed follow.
#[tauri::command(async)]
pub fn rename_path(
state: State<AppState>,
path: PathBuf,
name: String,
) -> Result<api::Outcome, String> {
state.core(move |core| {
if let Some(id) = core.workspace.linked(&path) {
let selected = api::select(&mut core.engine, &[id], gpx_engine::SelectMode::Replace);
let desc = core
.engine
.file_structure(&FileId(id))
.and_then(|structure| structure.desc.clone())
.unwrap_or_default();
return Ok(selected.merge(edit::rename_selection(core, name, desc)));
}
core.workspace.rename_path(&path, &name)?;
core.commit(None);
Ok(api::Outcome::failed_none())
})
}
/// Moves files and folders of the open folder into a folder of it. The open files follow.
#[tauri::command(async)]
pub fn move_paths(
state: State<AppState>,
paths: Vec<PathBuf>,
folder: PathBuf,
) -> Result<(), String> {
state.core(move |core| {
let result = core.workspace.move_paths(&paths, &folder);
core.commit(None);
result result
}) })
} }
/// Moves files and folders of the open folder to the trash. The open files that were among them
/// are closed, unless they have edits: they stay open, as gone from the disk.
#[tauri::command(async)]
pub fn delete_paths(state: State<AppState>, paths: Vec<PathBuf>) -> Result<api::Outcome, String> {
state.core(move |core| {
let outcome = core
.workspace
.delete_paths(&mut core.engine, &paths, to_trash)?;
core.commit(None);
Ok(outcome)
})
}
/// Moves files and folders to the trash of the system.
fn to_trash(paths: &[PathBuf]) -> Result<(), String> {
#[allow(unused_mut)]
let mut trash = trash::TrashContext::default();
// without asking the Finder, which needs a permission
#[cfg(target_os = "macos")]
{
use trash::macos::{DeleteMethod, TrashContextExtMacos};
trash.set_delete_method(DeleteMethod::NsFileManager);
}
trash.delete_all(paths).map_err(|error| {
eprintln!("the files cannot be deleted: {error}");
crate::workspace::DELETE_FAILED.to_owned()
})
}
+32 -6
View File
@@ -1,15 +1,31 @@
//! Editing the metadata, the style, the structure and the waypoints of the files, and the tools: //! Editing the metadata, the style, the structure and the waypoints of the files, and the tools:
//! reversing, cropping, reducing, timestamps, merging, cleaning... //! reversing, cropping, reducing, timestamps, merging, cleaning...
use gpx_engine::{CleanType, MergeType, SplitType, api}; use gpx_engine::{CleanType, FileId, MergeType, Selection, SplitType, api};
use tauri::State; use tauri::State;
use uuid::Uuid; use uuid::Uuid;
use crate::state::AppState; use crate::state::{AppState, Core};
/// Names and describes the selected files or tracks. A file on the disk is renamed with it (and back
/// when it is undone): its name is one that a file can have, and not the one of another file.
#[tauri::command(async)] #[tauri::command(async)]
pub fn metadata(state: State<AppState>, name: String, desc: String) -> api::Outcome { pub fn metadata(state: State<AppState>, name: String, desc: String) -> api::Outcome {
state.run(move |e| api::metadata(e, &name, &desc)) state.core(move |core| rename_selection(core, name, desc))
}
/// See `metadata`.
pub fn rename_selection(core: &mut Core, name: String, desc: String) -> api::Outcome {
let mut name = name;
if let Selection::File { file_ids } = core.engine.selection() {
for FileId(id) in file_ids.clone() {
match core.workspace.name_for(id, &name) {
Ok(valid) => name = valid,
Err(error) => return api::Outcome::failed(error),
}
}
}
core.edit(move |e| api::metadata(e, &name, &desc))
} }
#[tauri::command(async)] #[tauri::command(async)]
@@ -22,6 +38,16 @@ pub fn style(
state.run(move |e| api::style(e, color.as_deref(), opacity, width)) state.run(move |e| api::style(e, color.as_deref(), opacity, width))
} }
#[tauri::command(async)]
pub fn convert(state: State<AppState>) -> api::Outcome {
state.run(api::convert)
}
#[tauri::command(async)]
pub fn new_route(state: State<AppState>) -> api::Outcome {
state.run(api::new_route)
}
#[tauri::command(async)] #[tauri::command(async)]
pub fn new_track(state: State<AppState>) -> api::Outcome { pub fn new_track(state: State<AppState>) -> api::Outcome {
state.run(api::new_track) state.run(api::new_track)
@@ -139,12 +165,12 @@ pub fn create_artificial_timestamps(
#[tauri::command(async)] #[tauri::command(async)]
pub fn merge(state: State<AppState>, merge_type: MergeType, remove_gaps: bool) -> api::Outcome { pub fn merge(state: State<AppState>, merge_type: MergeType, remove_gaps: bool) -> api::Outcome {
state.run(move |e| api::merge(e, merge_type, remove_gaps)) state.core(move |core| core.creating(None, false, |e| api::merge(e, merge_type, remove_gaps)))
} }
#[tauri::command(async)] #[tauri::command(async)]
pub fn extract(state: State<AppState>) -> api::Outcome { pub fn extract(state: State<AppState>) -> api::Outcome {
state.run(api::extract) state.core(|core| core.creating(None, false, api::extract))
} }
#[tauri::command(async)] #[tauri::command(async)]
@@ -214,5 +240,5 @@ pub fn split(
at: u32, at: u32,
split_type: SplitType, split_type: SplitType,
) -> api::Outcome { ) -> api::Outcome {
state.run(move |e| api::split(e, revision, at, split_type)) state.core(move |core| core.creating(None, false, |e| api::split(e, revision, at, split_type)))
} }
+14 -4
View File
@@ -1,5 +1,7 @@
//! Creating, loading, deleting, moving, copying and pasting files and their elements, and undoing. //! Creating, loading, deleting, moving, copying and pasting files and their elements, and undoing.
use std::path::PathBuf;
use gpx_engine::api; use gpx_engine::api;
use tauri::State; use tauri::State;
use tauri::ipc::{InvokeBody, Request}; use tauri::ipc::{InvokeBody, Request};
@@ -14,8 +16,16 @@ pub fn new_file(
lng: Option<f64>, lng: Option<f64>,
lat: Option<f64>, lat: Option<f64>,
ele: Option<f64>, ele: Option<f64>,
route: Option<bool>,
folder: Option<PathBuf>,
) -> api::Outcome { ) -> api::Outcome {
state.run(move |e| api::new_file(e, &name, lng, lat, ele)) let route = route.unwrap_or_default();
// in `folder` if it is given, otherwise like the selected file, otherwise in the open folder
state.core(move |core| {
core.creating(folder, true, |e| {
api::new_file(e, &name, lng, lat, ele, route)
})
})
} }
/// The files are in the body, one after the other, the headers `lengths` and `names` (JSON, /// The files are in the body, one after the other, the headers `lengths` and `names` (JSON,
@@ -61,7 +71,7 @@ fn percent_decode(value: &str) -> Option<String> {
#[tauri::command(async)] #[tauri::command(async)]
pub fn duplicate(state: State<AppState>) -> api::Outcome { pub fn duplicate(state: State<AppState>) -> api::Outcome {
state.run(api::duplicate) state.core(|core| core.creating(None, false, api::duplicate))
} }
#[tauri::command(async)] #[tauri::command(async)]
@@ -105,10 +115,10 @@ pub fn move_elements(
#[tauri::command(async)] #[tauri::command(async)]
pub fn undo(state: State<AppState>) -> api::Outcome { pub fn undo(state: State<AppState>) -> api::Outcome {
state.run(api::undo) state.edit(api::undo)
} }
#[tauri::command(async)] #[tauri::command(async)]
pub fn redo(state: State<AppState>) -> api::Outcome { pub fn redo(state: State<AppState>) -> api::Outcome {
state.run(api::redo) state.edit(api::redo)
} }
+1
View File
@@ -12,3 +12,4 @@ pub mod queries;
pub mod selection; pub mod selection;
pub mod statistics; pub mod statistics;
pub mod storage; pub mod storage;
pub mod system;
-2
View File
@@ -79,7 +79,6 @@ pub fn export_file(
atemp: bool, atemp: bool,
power: bool, power: bool,
osm: bool, osm: bool,
as_route: bool,
) -> Bytes { ) -> Bytes {
let options = ExportOptions { let options = ExportOptions {
time, time,
@@ -88,7 +87,6 @@ pub fn export_file(
atemp, atemp,
power, power,
osm, osm,
as_route,
}; };
optional(state.read(move |e| api::export_file(e, file_id, options))) optional(state.read(move |e| api::export_file(e, file_id, options)))
} }
+10
View File
@@ -21,6 +21,16 @@ pub fn arrow_select(state: State<AppState>, down: bool, add: bool) -> api::Outco
state.run(move |e| api::arrow_select(e, down, add)) state.run(move |e| api::arrow_select(e, down, add))
} }
#[tauri::command(async)]
pub fn select_routes(
state: State<AppState>,
file_id: Uuid,
route_ids: Vec<Uuid>,
mode: SelectMode,
) -> api::Outcome {
state.run(move |e| api::select_routes(e, file_id, &route_ids, mode))
}
#[tauri::command(async)] #[tauri::command(async)]
pub fn select_tracks( pub fn select_tracks(
state: State<AppState>, state: State<AppState>,
+10 -12
View File
@@ -17,15 +17,7 @@ macro_rules! f64_getters {
)*}; )*};
} }
f64_getters!( f64_getters!(total_distance, slope_segment_slopes, lng, lat, ele);
total_distance,
slope,
slope_segment_slope,
slope_segment_distance,
lng,
lat,
ele
);
macro_rules! optional_f64_getters { macro_rules! optional_f64_getters {
($($name:ident),*) => {$( ($($name:ident),*) => {$(
@@ -38,10 +30,16 @@ macro_rules! optional_f64_getters {
optional_f64_getters!(speed, hr, cad, atemp, power); optional_f64_getters!(speed, hr, cad, atemp, power);
/// Timestamps in milliseconds since the epoch (`NO_TIME` when missing), as `i64`. /// The index of the first trackpoint of each smoothed elevation segment of the selection.
#[tauri::command(async)] #[tauri::command(async)]
pub fn timestamps(state: State<AppState>) -> Bytes { pub fn slope_segment_starts(state: State<AppState>) -> Bytes {
optional(state.read(|e| api::timestamps(e).map(i64_bytes))) u32s(&state.read(|e| api::slope_segment_starts(e).to_vec()))
}
/// What the buffers do not hold for each trackpoint of the selection, for the one at `index`.
#[tauri::command(async)]
pub fn selection_point(state: State<AppState>, index: usize) -> Option<api::SelectionPoint> {
state.read(move |e| api::selection_point(e, index))
} }
#[tauri::command(async)] #[tauri::command(async)]
+28
View File
@@ -0,0 +1,28 @@
//! What the app asks of the system, outside of the files.
use std::path::PathBuf;
use tauri::{AppHandle, Runtime};
use tauri_plugin_opener::OpenerExt;
/// Opens a web page (or an email to write) with the app of the system for it: the links of the
/// page are not followed in the window of the app. Only `http`, `https` and `mailto` addresses are
/// opened.
#[tauri::command(async)]
pub fn open_url<R: Runtime>(app: AppHandle<R>, url: String) -> Result<(), String> {
let allowed = ["http://", "https://", "mailto:"];
if !allowed.iter().any(|scheme| url.starts_with(scheme)) {
return Err(format!("{url} is not a web address"));
}
app.opener()
.open_url(url, None::<&str>)
.map_err(|error| error.to_string())
}
/// Shows a file or a folder in the file manager of the system (the Finder...).
#[tauri::command(async)]
pub fn reveal_path<R: Runtime>(app: AppHandle<R>, path: PathBuf) -> Result<(), String> {
app.opener()
.reveal_item_in_dir(path)
.map_err(|error| error.to_string())
}
+48 -4
View File
@@ -48,8 +48,12 @@ pub fn run() {
} }
}, },
)); ));
// on macOS, the app has a menu bar of its own (Windows and Linux have none by default)
#[cfg(target_os = "macos")]
let builder = builder.menu(menu);
register_commands(builder) register_commands(builder)
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.setup(|app| { .setup(|app| {
let handle = app.handle().clone(); let handle = app.handle().clone();
let emit: state::Emit = std::sync::Arc::new(move |event, payload| { let emit: state::Emit = std::sync::Arc::new(move |event, payload| {
@@ -79,6 +83,30 @@ pub fn run() {
}); });
} }
#[cfg(target_os = "macos")]
fn menu<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<tauri::menu::Menu<R>> {
use tauri::menu::{AboutMetadata, Menu, PredefinedMenuItem, Submenu};
// the name of the product, as macOS would otherwise name the items after the binary
let name = app.package_info().name.clone();
let about = AboutMetadata {
name: Some(name.clone()),
..Default::default()
};
Menu::with_items(
app,
&[&Submenu::with_items(
app,
&name,
true,
&[
&PredefinedMenuItem::about(app, Some(&format!("About {name}")), Some(about))?,
&PredefinedMenuItem::separator(app)?,
&PredefinedMenuItem::quit(app, Some(&format!("Quit {name}")))?,
],
)?],
)
}
/// Gives the builder the commands that reach the engine (which it has to be given as `AppState`). /// Gives the builder the commands that reach the engine (which it has to be given as `AppState`).
fn register_commands<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::Builder<R> { fn register_commands<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::Builder<R> {
builder.invoke_handler(tauri::generate_handler![ builder.invoke_handler(tauri::generate_handler![
@@ -86,6 +114,7 @@ fn register_commands<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::Bu
selection::select, selection::select,
selection::select_all, selection::select_all,
selection::arrow_select, selection::arrow_select,
selection::select_routes,
selection::select_tracks, selection::select_tracks,
selection::select_segments, selection::select_segments,
selection::select_waypoint_group, selection::select_waypoint_group,
@@ -106,6 +135,8 @@ fn register_commands<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::Bu
// edits and tools // edits and tools
edit::metadata, edit::metadata,
edit::style, edit::style,
edit::convert,
edit::new_route,
edit::new_track, edit::new_track,
edit::new_track_segment, edit::new_track_segment,
edit::new_waypoint, edit::new_waypoint,
@@ -144,9 +175,8 @@ fn register_commands<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::Bu
queries::waypoint_coordinates, queries::waypoint_coordinates,
// statistics // statistics
statistics::total_distance, statistics::total_distance,
statistics::slope, statistics::slope_segment_starts,
statistics::slope_segment_slope, statistics::slope_segment_slopes,
statistics::slope_segment_distance,
statistics::lng, statistics::lng,
statistics::lat, statistics::lat,
statistics::ele, statistics::ele,
@@ -155,7 +185,7 @@ fn register_commands<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::Bu
statistics::cad, statistics::cad,
statistics::atemp, statistics::atemp,
statistics::power, statistics::power,
statistics::timestamps, statistics::selection_point,
statistics::selection_statistics, statistics::selection_statistics,
statistics::slice_statistics, statistics::slice_statistics,
statistics::surface_starts, statistics::surface_starts,
@@ -184,9 +214,23 @@ fn register_commands<R: tauri::Runtime>(builder: tauri::Builder<R>) -> tauri::Bu
// the file system // the file system
disk::workspace_state, disk::workspace_state,
disk::open_files, disk::open_files,
disk::open_paths,
disk::keep_files,
disk::open_folder, disk::open_folder,
disk::close_folder, disk::close_folder,
disk::close_files,
disk::save_files, disk::save_files,
disk::save_file_as,
disk::reload_files,
disk::export_files,
disk::list_folder,
disk::create_folder,
disk::rename_path,
disk::move_paths,
disk::delete_paths,
// the system
system::open_url,
system::reveal_path,
]) ])
} }
+156 -24
View File
@@ -5,7 +5,7 @@
//! watcher of the open folder), and the commands send that thread jobs: they are done one after the //! watcher of the open folder), and the commands send that thread jobs: they are done one after the
//! other, in the order they were sent. //! other, in the order they were sent.
use std::path::{Path, PathBuf}; use std::path::PathBuf;
use std::sync::mpsc::{self, Sender}; use std::sync::mpsc::{self, Sender};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::thread; use std::thread;
@@ -18,7 +18,7 @@ use notify_debouncer_mini::{DebounceEventResult, Debouncer, new_debouncer};
use serde::Serialize; use serde::Serialize;
use crate::store::FileStorage; use crate::store::FileStorage;
use crate::workspace::{Workspace, WorkspaceState}; use crate::workspace::{Workspace, WorkspaceState, signature};
/// Tells the frontend that something happened that it did not ask for: a name and a JSON value. /// Tells the frontend that something happened that it did not ask for: a name and a JSON value.
pub type Emit = Arc<dyn Fn(&str, serde_json::Value) + Send + Sync>; pub type Emit = Arc<dyn Fn(&str, serde_json::Value) + Send + Sync>;
@@ -32,6 +32,8 @@ pub struct Core {
storage: Arc<FileStorage>, storage: Arc<FileStorage>,
persister: Persister, persister: Persister,
watcher: Option<Debouncer<notify::RecommendedWatcher>>, watcher: Option<Debouncer<notify::RecommendedWatcher>>,
/// What the watcher watches, see `Workspace::watched`.
watching: Vec<(PathBuf, bool)>,
jobs: Sender<Job>, jobs: Sender<Job>,
emit: Emit, emit: Emit,
} }
@@ -50,6 +52,7 @@ impl Core {
persister: Persister::default(), persister: Persister::default(),
storage, storage,
watcher: None, watcher: None,
watching: vec![],
jobs, jobs,
emit, emit,
}; };
@@ -86,15 +89,67 @@ impl Core {
if paths.is_empty() { if paths.is_empty() {
return; return;
} }
let outcome = self.workspace.open_files(&mut self.engine, paths, true); let outcome = self.workspace.open(&mut self.engine, paths, false);
self.keep_workspace();
self.persist();
(self.emit)("files-opened", to_json(&outcome)); (self.emit)("files-opened", to_json(&outcome));
self.announce(None); self.commit(None);
} }
pub fn keep_workspace(&self) { /// Runs `f` on the engine, which can change: what changed is kept, and the files on the disk are
/// renamed like the files of the engine were (an edit of the name, or its undo). Gives what
/// could not be renamed.
fn editing<T>(&mut self, f: impl FnOnce(&mut Engine) -> T) -> (T, Vec<String>) {
let names = self.workspace.names(&self.engine);
let preview = self.workspace.preview;
let preview_before = preview.and_then(|id| signature(&self.engine, id));
let result = f(&mut self.engine);
// the preview is open for good once something is done to it
let kept = preview.is_some_and(|id| signature(&self.engine, id) != preview_before);
if let Some(id) = preview.filter(|_| kept) {
self.workspace.keep(&[id]);
}
let (renamed, errors) = self.workspace.follow_names(&mut self.engine, &names);
// and it goes when something else is selected
let dropped = self.workspace.drop_preview(&mut self.engine);
if kept || renamed || dropped.is_some() {
self.commit(dropped.as_ref());
} else {
self.persist();
}
(result, errors)
}
/// Like `editing`, for an edit that can rename files: the outcome tells the first thing that
/// could not be renamed (a key of the dictionaries of the frontend, which shows it).
pub fn edit(&mut self, f: impl FnOnce(&mut Engine) -> api::Outcome) -> api::Outcome {
let (outcome, errors) = self.editing(f);
match errors.first() {
Some(error) if !outcome.has_error() => {
outcome.merge(api::Outcome::failed(error.clone()))
}
_ => outcome,
}
}
/// After files were written (`result`): what changed in the engine (the files are called like
/// the files they were written to) is kept and told, with where the files are. Says whether
/// they were written.
pub fn wrote(&mut self, result: Result<api::Outcome, String>) -> Result<bool, String> {
self.commit(result.as_ref().ok().filter(|outcome| outcome.changed));
result.map(|_| true)
}
/// After a change of the files or of where they are: the workspace and the engine are kept, and
/// the frontend is told (`outcome` is what changed in the engine, if it did not ask for it).
pub fn commit(&mut self, outcome: Option<&api::Outcome>) {
self.keep_workspace();
self.persist();
self.announce(outcome);
}
/// Writes the workspace, and watches what it holds (a file may have been opened or closed).
pub fn keep_workspace(&mut self) {
self.workspace.write(self.storage.root()); self.workspace.write(self.storage.root());
self.watch();
} }
pub fn workspace_state(&self) -> WorkspaceState { pub fn workspace_state(&self) -> WorkspaceState {
@@ -109,13 +164,16 @@ impl Core {
(self.emit)("workspace-changed", to_json(&self.workspace_state())); (self.emit)("workspace-changed", to_json(&self.workspace_state()));
} }
/// Watches the open folder (and only it). /// Watches the open folder, and the folders of the files that are linked outside of it. Does
/// nothing if that is what is watched already.
pub fn watch(&mut self) { pub fn watch(&mut self) {
self.watcher = None; let wanted = self.workspace.watched();
let Some(folder) = self.workspace.folder.clone() else { if wanted == self.watching && (self.watcher.is_some() || wanted.is_empty()) {
return; return;
}; }
if !Path::new(&folder).is_dir() { self.watcher = None;
self.watching = wanted.clone();
if wanted.is_empty() {
return; return;
} }
let jobs = self.jobs.clone(); let jobs = self.jobs.clone();
@@ -131,22 +189,86 @@ impl Core {
); );
match debouncer { match debouncer {
Ok(mut debouncer) => { Ok(mut debouncer) => {
match debouncer.watcher().watch(&folder, RecursiveMode::Recursive) { for (path, recursive) in &wanted {
Ok(()) => self.watcher = Some(debouncer), let mode = if *recursive {
Err(error) => eprintln!("the folder cannot be watched: {error}"), RecursiveMode::Recursive
} else {
RecursiveMode::NonRecursive
};
if let Err(error) = debouncer.watcher().watch(path, mode) {
eprintln!("{} cannot be watched: {error}", path.display());
} }
} }
Err(error) => eprintln!("the folder cannot be watched: {error}"), self.watcher = Some(debouncer);
}
Err(error) => eprintln!("the files cannot be watched: {error}"),
} }
} }
fn on_disk_changed(&mut self, paths: &[PathBuf]) { fn on_disk_changed(&mut self, paths: &[PathBuf]) {
let outcome = self.workspace.reconcile(&mut self.engine, Some(paths)); // the tree of the folder shows the disk
if let Some(outcome) = outcome { let in_folder: Vec<&PathBuf> = paths
self.workspace.write(self.storage.root()); .iter()
self.persist(); .filter(|path| {
self.announce(Some(&outcome)); self.workspace
.folder
.as_ref()
.is_some_and(|f| path.starts_with(f))
})
.collect();
if !in_folder.is_empty() {
(self.emit)("folder-changed", to_json(&in_folder));
} }
if let Some(outcome) = self.workspace.reconcile(&mut self.engine, Some(paths)) {
self.commit(Some(&outcome));
}
}
/// Runs `f`, which creates files (a new file, a copy, the result of a split...). Where they go
/// when they are saved is the folder of the files they come from (the selected ones, before
/// `f`), if it is in the open folder. `default_folder` says whether a file with nothing to
/// come from goes in the open folder: a new file does.
pub fn creating(
&mut self,
folder: Option<PathBuf>,
default_folder: bool,
f: impl FnOnce(&mut Engine) -> api::Outcome,
) -> api::Outcome {
let selected: Vec<uuid::Uuid> = self
.engine
.selection()
.file_ids()
.into_iter()
.map(|gpx_engine::FileId(id)| id)
.collect();
let dir = folder
.map(|folder| crate::workspace::canonical(&folder))
.or_else(|| self.workspace.source_dir(&selected))
.or_else(|| {
default_folder
.then(|| self.workspace.folder.clone())
.flatten()
});
let mut outcome = f(&mut self.engine);
if let Some(dir) = dir {
self.workspace.inherit(outcome.added(), &dir);
// what is made in a folder is written there at once, so that the tree shows it
let made: Vec<uuid::Uuid> = outcome
.added()
.iter()
.filter_map(|id| uuid::Uuid::parse_str(id).ok())
.filter(|id| self.workspace.dirs.contains_key(id))
.collect();
match self.workspace.save_new(&mut self.engine, &made) {
Ok(renamed) => outcome = outcome.merge(renamed),
Err(error) if !outcome.has_error() => {
outcome = outcome.merge(api::Outcome::failed(error));
}
Err(_) => {}
}
}
self.commit(None);
outcome
} }
} }
@@ -201,15 +323,25 @@ impl AppState {
result.recv().expect("the engine thread stopped") result.recv().expect("the engine thread stopped")
} }
/// Runs `f` on the engine, which can change: what changed is kept. /// Runs `f` on the engine, which can change: what changed is kept (see `Core::editing`).
pub fn run<T: Send + 'static>(&self, f: impl FnOnce(&mut Engine) -> T + Send + 'static) -> T { pub fn run<T: Send + 'static>(&self, f: impl FnOnce(&mut Engine) -> T + Send + 'static) -> T {
self.core(move |core| { self.core(move |core| {
let result = f(&mut core.engine); let (result, errors) = core.editing(f);
core.persist(); for error in errors {
eprintln!("{error}");
}
result result
}) })
} }
/// Like `run`, for an edit that can rename files: the outcome tells what could not be renamed.
pub fn edit(
&self,
f: impl FnOnce(&mut Engine) -> api::Outcome + Send + 'static,
) -> api::Outcome {
self.core(move |core| core.edit(f))
}
/// Reads from the engine. /// Reads from the engine.
pub fn read<T: Send + 'static>(&self, f: impl FnOnce(&Engine) -> T + Send + 'static) -> T { pub fn read<T: Send + 'static>(&self, f: impl FnOnce(&Engine) -> T + Send + 'static) -> T {
self.core(move |core| f(&core.engine)) self.core(move |core| f(&core.engine))
+412 -1
View File
@@ -142,6 +142,18 @@ fn reads_the_statistics_of_the_selection() {
// optional arrays start with a byte telling whether they are there // optional arrays start with a byte telling whether they are there
let hr = app.bytes("hr", json!({})); let hr = app.bytes("hr", json!({}));
assert_eq!(hr[0], 0); assert_eq!(hr[0], 0);
// the slope segments are intervals of trackpoints
let starts = app.bytes("slope_segment_starts", json!({}));
let slopes = f64s(&app.bytes("slope_segment_slopes", json!({})));
assert_eq!(starts.len() / 4, slopes.len());
assert!(!slopes.is_empty() && slopes.len() < distance.len());
let point = app.call("selection_point", json!({ "index": 1 }));
assert!(point["slopeSegmentDistance"].is_number());
assert!(point.get("time").is_none());
assert_eq!(
app.call("selection_point", json!({ "index": distance.len() })),
Value::Null
);
assert!( assert!(
app.call("slice_statistics", json!({ "start": 1, "end": 3 }))["totalDistance"].is_number() app.call("slice_statistics", json!({ "start": 1, "end": 3 }))["totalDistance"].is_number()
); );
@@ -245,7 +257,7 @@ fn waypoints_exports_and_routing_revisions() {
let exported = app.bytes( let exported = app.bytes(
"export_file", "export_file",
json!({ "fileId": file, "time": true, "hr": true, "cad": true, "atemp": true, "power": true, "osm": true, "asRoute": false }), json!({ "fileId": file, "time": true, "hr": true, "cad": true, "atemp": true, "power": true, "osm": true }),
); );
assert_eq!(exported[0], 1); assert_eq!(exported[0], 1);
assert!( assert!(
@@ -363,3 +375,402 @@ fn a_file_opened_with_the_app_is_opened_selected_and_announced() {
assert!(events.iter().any(|(name, _)| name == "workspace-changed")); assert!(events.iter().any(|(name, _)| name == "workspace-changed"));
std::fs::remove_dir_all(directory).unwrap(); std::fs::remove_dir_all(directory).unwrap();
} }
#[test]
fn files_are_opened_closed_and_made_in_a_folder() {
let app = App::new();
let folder = std::env::temp_dir().join(format!("gpx-tests-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&folder).unwrap();
let path = folder.join("track.gpx");
std::fs::copy("../gpx-rs/data/simple.gpx", &path).unwrap();
// opened from the disk, clean
let outcome = app.call("open_paths", json!({ "paths": [path] }));
assert_eq!(outcome["added"].as_array().unwrap().len(), 1);
let id = outcome["added"][0].as_str().unwrap().to_string();
let state = app.call("workspace_state", json!({}));
assert_eq!(state["files"][&id]["dirty"], false);
assert!(state["files"][&id]["path"].is_string());
// a new file made in a folder is written there at once, so that the tree shows it
let created = app.call(
"new_file",
json!({ "name": "route", "lng": 4.4, "lat": 50.8, "folder": folder }),
);
let new_id = created["added"][0].as_str().unwrap().to_string();
let state = app.call("workspace_state", json!({}));
assert_eq!(state["files"][&new_id]["dirty"], false);
assert!(state["files"][&new_id]["path"].is_string());
assert!(folder.join("route.gpx").exists());
// the tree needs an open folder
assert_eq!(app.call("list_folder", json!({})), json!([]));
// closing: what is saved goes, what has edits stays unless it is discarded
app.call("select", json!({ "fileIds": [new_id], "mode": "replace" }));
app.call("style", json!({ "color": "#ff0000" }));
app.call("close_files", json!({ "fileIds": [id, new_id] }));
assert_eq!(app.call("file_order", json!({})), json!([new_id]));
app.call(
"close_files",
json!({ "fileIds": [new_id], "unsaved": "discard" }),
);
assert_eq!(app.call("file_order", json!({})), json!([]));
std::fs::remove_dir_all(folder).unwrap();
}
/// A file that is open from outside of any folder follows the disk too.
#[test]
fn a_file_outside_the_open_folder_is_read_again_when_the_disk_changes() {
use std::sync::mpsc;
use std::time::Duration;
let (events, received) = mpsc::channel();
let directory = std::env::temp_dir().join(format!("gpx-store-{}", uuid::Uuid::new_v4()));
let state = crate::state::AppState::open(
directory.clone(),
std::sync::Arc::new(move |name, _| {
let _ = events.send(name.to_string());
}),
)
.unwrap();
let folder = std::env::temp_dir().join(format!("gpx-tests-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&folder).unwrap();
let path = folder.join("track.gpx");
std::fs::copy("../gpx-rs/data/simple.gpx", &path).unwrap();
let opened = path.clone();
let id = state.core(move |core| {
let outcome = core.workspace.open_files(&mut core.engine, &[opened], true);
core.keep_workspace();
outcome.added()[0].clone()
});
let id = uuid::Uuid::parse_str(&id).unwrap();
// the name comes from the path, which does not change: the content does
let signature = |state: &crate::state::AppState| {
state.core(move |core| crate::workspace::signature(&core.engine, id))
};
let before = signature(&state);
std::fs::copy("../gpx-rs/data/with_time.gpx", &path).unwrap();
// the debounced watcher tells the frontend that the engine changed
let mut changed = false;
while let Ok(event) = received.recv_timeout(Duration::from_secs(10)) {
if event == "engine-changed" {
changed = true;
break;
}
}
assert!(changed, "the file was not read again");
assert_ne!(signature(&state), before);
std::fs::remove_dir_all(folder).unwrap();
std::fs::remove_dir_all(directory).unwrap();
}
#[test]
fn files_made_from_a_file_of_the_open_folder_are_written_next_to_it_at_once() {
let state = crate::state::AppState::temporary();
let folder = std::env::temp_dir().join(format!("gpx-tests-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(folder.join("sub")).unwrap();
std::fs::copy("../gpx-rs/data/simple.gpx", folder.join("sub/track.gpx")).unwrap();
let sub = folder.join("sub");
let (copied, new_file) = state.core(move |core| {
core.workspace.open_folder(&mut core.engine, &folder, false);
let opened = core
.workspace
.open_files(&mut core.engine, &[sub.join("track.gpx")], true);
core.keep_workspace();
assert_eq!(opened.added().len(), 1);
let copy = core.creating(None, false, gpx_engine::api::duplicate);
let fresh = core.creating(None, true, |e| {
gpx_engine::api::new_file(e, "fresh", None, None, None, false)
});
// a copy of the file goes next to it, a new file with nothing selected in the selection
// of the file that was just made
(copy, fresh)
});
let sub = std::env::temp_dir();
let _ = sub;
assert_eq!(copied.added().len(), 1);
assert_eq!(new_file.added().len(), 1);
let status = state.core(|core| core.workspace_state());
assert!(status.files.values().all(|file| !file.dirty));
let paths: Vec<String> = status
.files
.values()
.map(|file| file.path.clone().unwrap())
.collect();
// the copy is in the subfolder, named after the file
assert!(
paths.iter().any(|path| path.ends_with("sub/track (2).gpx")),
"{paths:?}"
);
// the new file went where the selected file is
assert!(
paths.iter().any(|path| path.ends_with("sub/fresh.gpx")),
"{paths:?}"
);
}
#[test]
fn the_parts_of_a_split_are_numbered_from_one() {
let state = crate::state::AppState::temporary();
let folder = std::env::temp_dir().join(format!("gpx-tests-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&folder).unwrap();
std::fs::copy("../gpx-rs/data/simple.gpx", folder.join("track.gpx")).unwrap();
let root = folder.clone();
let outcome = state.core(move |core| {
core.workspace.open_folder(&mut core.engine, &root, false);
core.workspace
.open_files(&mut core.engine, &[root.join("track.gpx")], true);
core.keep_workspace();
let revision = core.engine.routing().revision;
core.creating(None, false, |e| {
gpx_engine::api::split(e, revision, 2, gpx_engine::SplitType::Files)
})
});
assert_eq!(outcome.added().len(), 2, "{:?}", outcome.added());
let mut names: Vec<String> = std::fs::read_dir(&folder)
.unwrap()
.flatten()
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect();
names.sort();
// the file stays, its two parts are numbered from 1
assert_eq!(names, ["track (1).gpx", "track (2).gpx", "track.gpx"]);
std::fs::remove_dir_all(folder).unwrap();
}
#[test]
fn files_can_be_reordered() {
let app = App::new();
let mut ids = vec![];
for name in ["a", "b", "c"] {
let outcome = app.call("new_file", json!({ "name": name }));
ids.push(outcome["added"][0].as_str().unwrap().to_string());
}
assert_eq!(app.call("file_order", json!({})), json!(ids));
let outcome = app.call("reorder", json!({ "fileIds": [ids[2]], "index": 0 }));
assert_eq!(outcome["changed"], true);
assert_eq!(outcome["orderChanged"], true);
assert_eq!(
app.call("file_order", json!({})),
json!([ids[2], ids[0], ids[1]])
);
}
/// A folder of its own, with GPX files whose name inside is not the name of the file.
fn folder_with(files: &[&str]) -> std::path::PathBuf {
let folder = std::env::temp_dir().join(format!("gpx-names-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&folder).unwrap();
for name in files {
std::fs::write(
folder.join(format!("{name}.gpx")),
r#"<?xml version="1.0"?><gpx version="1.1" creator="test"><metadata><name>Inside</name></metadata><trk><trkseg><trkpt lat="50.0" lon="4.0"></trkpt><trkpt lat="50.1" lon="4.1"></trkpt></trkseg></trk></gpx>"#,
)
.unwrap();
}
folder
}
fn name_of(app: &App, id: &str) -> String {
app.call("file_structure", json!({ "fileId": id }))["name"]
.as_str()
.unwrap()
.to_string()
}
#[test]
fn a_file_is_called_like_its_file_and_renaming_it_renames_the_file() {
let app = App::new();
let folder = folder_with(&["outside", "taken"]);
let outcome = app.call(
"open_paths",
json!({ "paths": [folder.join("outside.gpx")] }),
);
let id = outcome["added"][0].as_str().unwrap().to_string();
// the name in the file is not the name of the file: the file wins, and nothing is unsaved
assert_eq!(name_of(&app, &id), "outside");
assert_eq!(
app.call("workspace_state", json!({}))["files"][&id]["dirty"],
false
);
// renaming renames the file on the disk
app.call("select", json!({ "fileIds": [id], "mode": "replace" }));
let outcome = app.call("metadata", json!({ "name": "renamed", "desc": "" }));
assert!(outcome.get("error").is_none());
assert!(folder.join("renamed.gpx").exists());
assert!(!folder.join("outside.gpx").exists());
let path = app.call("workspace_state", json!({}))["files"][&id]["path"].clone();
assert!(path.as_str().unwrap().ends_with("renamed.gpx"));
// the file had nothing unsaved: it is written with its new name, and still has nothing unsaved
let dirty = |app: &App| app.call("workspace_state", json!({}))["files"][&id]["dirty"].clone();
let inside = |name: &str| std::fs::read_to_string(folder.join(format!("{name}.gpx"))).unwrap();
assert_eq!(dirty(&app), false);
assert!(inside("renamed").contains("<name>renamed</name>"));
// undone and redone, on the disk too, still with nothing unsaved
app.call("undo", json!({}));
assert_eq!(name_of(&app, &id), "outside");
assert!(folder.join("outside.gpx").exists() && !folder.join("renamed.gpx").exists());
assert_eq!(dirty(&app), false);
assert!(inside("outside").contains("<name>outside</name>"));
app.call("redo", json!({}));
assert_eq!(name_of(&app, &id), "renamed");
assert!(folder.join("renamed.gpx").exists() && !folder.join("outside.gpx").exists());
assert_eq!(dirty(&app), false);
// the name of another file is not taken, nothing changes
let outcome = app.call("metadata", json!({ "name": "taken", "desc": "" }));
assert_eq!(outcome["error"], crate::workspace::NAME_TAKEN);
assert_eq!(name_of(&app, &id), "renamed");
assert!(folder.join("renamed.gpx").exists());
// a name is one that a file can have
app.call("metadata", json!({ "name": "a/b", "desc": "" }));
assert_eq!(name_of(&app, &id), "a_b");
assert!(folder.join("a_b.gpx").exists());
// a file with edits is renamed, but not written: its edits are not saved for it
app.call("style", json!({ "color": "#ff0000" }));
assert_eq!(dirty(&app), true);
app.call("metadata", json!({ "name": "edited", "desc": "" }));
assert!(folder.join("edited.gpx").exists());
assert_eq!(dirty(&app), true);
assert!(!inside("edited").contains("ff0000"));
app.call("undo", json!({}));
app.call("undo", json!({}));
assert_eq!(name_of(&app, &id), "a_b");
assert_eq!(dirty(&app), false);
// only the case changes: the same file
app.call("metadata", json!({ "name": "A_B", "desc": "" }));
assert_eq!(name_of(&app, &id), "A_B");
let names: Vec<String> = std::fs::read_dir(&folder)
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert!(names.contains(&"A_B.gpx".to_string()));
assert_eq!(names.len(), 2);
std::fs::remove_dir_all(folder).unwrap();
}
#[test]
fn a_new_file_written_under_another_name_takes_it() {
let app = App::new();
let folder = folder_with(&["route"]);
// a new file made in the folder is written there at once: route.gpx is taken
let created = app.call(
"new_file",
json!({ "name": "route", "lng": 4.4, "lat": 50.8, "folder": folder }),
);
let id = created["added"][0].as_str().unwrap().to_string();
assert!(folder.join("route (2).gpx").exists());
assert_eq!(name_of(&app, &id), "route (2)");
assert_eq!(
app.call("workspace_state", json!({}))["files"][&id]["dirty"],
false
);
// which is not an edit to undo
app.call("select", json!({ "fileIds": [id], "mode": "replace" }));
assert_eq!(app.call("can_undo", json!({})), false);
std::fs::remove_dir_all(folder).unwrap();
}
#[test]
fn a_file_open_as_a_preview_is_replaced_until_it_is_kept_or_edited() {
let app = App::new();
let folder = folder_with(&["a", "b", "c", "d"]);
let path = |name: &str| folder.join(format!("{name}.gpx"));
let open = |name: &str, preview: bool| {
app.call(
"open_paths",
json!({ "paths": [path(name)], "preview": preview }),
)
};
let state = |app: &App| app.call("workspace_state", json!({}))["files"].clone();
let names = |app: &App| -> Vec<String> {
let order = app.call("file_order", json!({}));
order
.as_array()
.unwrap()
.iter()
.map(|id| name_of(app, id.as_str().unwrap()))
.collect()
};
let a = open("a", true)["added"][0].as_str().unwrap().to_string();
assert_eq!(state(&app)[&a]["preview"], true);
// the next preview replaces it
let b = open("b", true)["added"][0].as_str().unwrap().to_string();
assert_eq!(names(&app), ["b"]);
assert_eq!(state(&app)[&b]["preview"], true);
// opened for good (a double click), it stays when another one is previewed
open("b", false);
assert_eq!(state(&app)[&b]["preview"], false);
let c = open("c", true)["added"][0].as_str().unwrap().to_string();
assert_eq!(names(&app), ["b", "c"]);
// something done to the preview keeps it too
app.call("style", json!({ "color": "#ff0000" }));
assert_eq!(state(&app)[&c]["preview"], false);
open("d", true);
assert_eq!(names(&app), ["b", "c", "d"]);
// several files at once are open for good
app.call(
"open_paths",
json!({ "paths": [path("a"), path("d")], "preview": true }),
);
assert_eq!(names(&app), ["b", "c", "d", "a"]);
let files = state(&app);
assert!(
files
.as_object()
.unwrap()
.values()
.all(|file| file["preview"] == false)
);
std::fs::remove_dir_all(folder).unwrap();
}
#[test]
fn the_preview_goes_when_something_else_is_selected_unless_it_was_kept() {
let app = App::new();
let folder = folder_with(&["a", "b", "c"]);
let open = |name: &str, preview: bool| {
app.call(
"open_paths",
json!({ "paths": [folder.join(format!("{name}.gpx"))], "preview": preview }),
)["added"][0]
.as_str()
.map(str::to_string)
};
let order = |app: &App| app.call("file_order", json!({})).as_array().unwrap().len();
let a = open("a", false).unwrap();
// looked at, then something else is selected: it goes
let b = open("b", true).unwrap();
assert_eq!(order(&app), 2);
app.call("select", json!({ "fileIds": [a], "mode": "replace" }));
assert_eq!(order(&app), 1);
// nothing selected: it stays
open("b", true);
app.call("select", json!({ "fileIds": [], "mode": "replace" }));
assert_eq!(order(&app), 2);
// kept (hidden, its menu opened...): it stays when something else is selected
let c = open("c", true).unwrap();
assert_eq!(order(&app), 2, "c replaces b");
app.call("keep_files", json!({ "fileIds": [c] }));
assert_eq!(
app.call("workspace_state", json!({}))["files"][&c]["preview"],
false
);
app.call("select", json!({ "fileIds": [a], "mode": "replace" }));
assert_eq!(order(&app), 2);
let _ = b;
std::fs::remove_dir_all(folder).unwrap();
}
+1313 -523
View File
File diff suppressed because it is too large. Load diff
+4 -2
View File
@@ -6,7 +6,7 @@
"build": { "build": {
"beforeDevCommand": "npm --prefix ../website run dev -- --port 4283", "beforeDevCommand": "npm --prefix ../website run dev -- --port 4283",
"devUrl": "http://localhost:4283", "devUrl": "http://localhost:4283",
"beforeBuildCommand": "BASE_PATH='' npm --prefix ../website run build", "beforeBuildCommand": "npm --prefix ../website run build:desktop",
"frontendDist": "../website/build" "frontendDist": "../website/build"
}, },
"app": { "app": {
@@ -18,7 +18,9 @@
"width": 1400, "width": 1400,
"height": 900, "height": 900,
"minWidth": 800, "minWidth": 800,
"minHeight": 600 "minHeight": 600,
"maximized": true,
"dragDropEnabled": false
} }
], ],
"security": { "csp": null } "security": { "csp": null }
+11
View File
@@ -18,6 +18,17 @@ pub fn style(color: Option<String>, opacity: Option<f64>, width: Option<f64>) ->
run(|e| api::style(e, color.as_deref(), opacity, width)) run(|e| api::style(e, color.as_deref(), opacity, width))
} }
/// Converts the selected routes to tracks, or the selected tracks or segments to routes.
#[wasm_bindgen]
pub fn convert() -> Outcome {
run(api::convert)
}
#[wasm_bindgen]
pub fn new_route() -> Outcome {
run(api::new_route)
}
#[wasm_bindgen] #[wasm_bindgen]
pub fn new_track() -> Outcome { pub fn new_track() -> Outcome {
run(api::new_track) run(api::new_track)
+11 -4
View File
@@ -10,11 +10,18 @@ use crate::ts::*;
// File commands // File commands
/// Creates a file and selects it. With `lng` and `lat`, it starts with a track and a segment that /// Creates a file and selects it. With `route`, it starts with a route. With `lng` and `lat`, it
/// hold that trackpoint (at `ele`, 0 by default). /// starts with a segment that holds that trackpoint (at `ele`, 0 by default): the one of its route
/// with `route`, of a track otherwise.
#[wasm_bindgen] #[wasm_bindgen]
pub fn new_file(name: &str, lng: Option<f64>, lat: Option<f64>, ele: Option<f64>) -> Outcome { pub fn new_file(
run(|e| api::new_file(e, name, lng, lat, ele)) name: &str,
lng: Option<f64>,
lat: Option<f64>,
ele: Option<f64>,
route: bool,
) -> Outcome {
run(|e| api::new_file(e, name, lng, lat, ele, route))
} }
/// Loads files as a single command (one undo step). The files are in `data`, one after the /// Loads files as a single command (one undo step). The files are in `data`, one after the
+1 -5
View File
@@ -45,10 +45,8 @@ pub fn selection_statistics() -> GlobalStatistics {
} }
/// A file as GPX (UTF-8 bytes), `undefined` if it does not exist. The options say which data of the trackpoints /// A file as GPX (UTF-8 bytes), `undefined` if it does not exist. The options say which data of the trackpoints
/// to keep, and with `as_route` that the segments are written as routes (`rte`) instead of tracks: /// to keep.
/// the anchors are the route points, and the trackpoints between them the points of their path.
#[wasm_bindgen] #[wasm_bindgen]
#[allow(clippy::too_many_arguments)]
pub fn export_file( pub fn export_file(
file_id: &str, file_id: &str,
time: bool, time: bool,
@@ -57,7 +55,6 @@ pub fn export_file(
atemp: bool, atemp: bool,
power: bool, power: bool,
osm: bool, osm: bool,
as_route: bool,
) -> Option<Vec<u8>> { ) -> Option<Vec<u8>> {
let id = parse_uuid(file_id)?; let id = parse_uuid(file_id)?;
let options = ExportOptions { let options = ExportOptions {
@@ -67,7 +64,6 @@ pub fn export_file(
atemp, atemp,
power, power,
osm, osm,
as_route,
}; };
with_engine(|e| api::export_file(e, id, options)).flatten() with_engine(|e| api::export_file(e, id, options)).flatten()
} }
+13 -11
View File
@@ -25,9 +25,6 @@ macro_rules! stats_getter {
stats_getter!(total_distance, Float64Array); stats_getter!(total_distance, Float64Array);
stats_getter!(elevation_gain, Float64Array); stats_getter!(elevation_gain, Float64Array);
stats_getter!(elevation_loss, Float64Array); stats_getter!(elevation_loss, Float64Array);
stats_getter!(slope, Float64Array);
stats_getter!(slope_segment_slope, Float64Array);
stats_getter!(slope_segment_distance, Float64Array);
stats_getter!(lng, Float64Array); stats_getter!(lng, Float64Array);
stats_getter!(lat, Float64Array); stats_getter!(lat, Float64Array);
stats_getter!(ele, Float64Array); stats_getter!(ele, Float64Array);
@@ -49,14 +46,6 @@ optional_stats_getter!(moving_distance, Float64Array);
optional_stats_getter!(total_time, BigInt64Array); optional_stats_getter!(total_time, BigInt64Array);
optional_stats_getter!(moving_time, BigInt64Array); optional_stats_getter!(moving_time, BigInt64Array);
optional_stats_getter!(speed, Float64Array); optional_stats_getter!(speed, Float64Array);
// Timestamps in milliseconds since the epoch (`NO_TIME`, the smallest i64, when missing).
optional_stats_getter!(timestamps, BigInt64Array);
/// Value of the timestamps of the trackpoints that have none.
#[wasm_bindgen]
pub fn no_time() -> i64 {
gpx_engine::NO_TIME
}
optional_stats_getter!(hr, Float64Array); optional_stats_getter!(hr, Float64Array);
optional_stats_getter!(cad, Float64Array); optional_stats_getter!(cad, Float64Array);
@@ -96,6 +85,19 @@ categories_getters!(highway_starts, highway_values, highways);
categories_getters!(sac_scale_starts, sac_scale_values, sac_scales); categories_getters!(sac_scale_starts, sac_scale_values, sac_scales);
categories_getters!(mtb_scale_starts, mtb_scale_values, mtb_scales); categories_getters!(mtb_scale_starts, mtb_scale_values, mtb_scales);
// The smoothed elevation segments of the selection, over which the slope is averaged, as
// intervals of trackpoints: `slope_segment_starts` holds the index of the first trackpoint of each
// segment, and `slope_segment_slopes` its slope (%). Their length is in `selection_point`.
stats_getter!(slope_segment_starts, Uint32Array);
stats_getter!(slope_segment_slopes, Float64Array);
/// What the buffers do not hold for each trackpoint of the selection, for the one at `index`
/// (numbered as in the buffers), `undefined` if there is none.
#[wasm_bindgen]
pub fn selection_point(index: usize) -> SelectionPoint {
optional_js(with_engine(|e| api::selection_point(e, index)).flatten()).unchecked_into()
}
/// For each trackpoint of the selection (numbered as in the statistics), the highest tolerance /// For each trackpoint of the selection (numbered as in the statistics), the highest tolerance
/// (m) of `reduce` that keeps it: infinite for the ends of the segments. /// (m) of `reduce` that keeps it: infinite for the ends of the segments.
#[wasm_bindgen] #[wasm_bindgen]
+8
View File
@@ -41,6 +41,14 @@ pub fn arrow_select(down: bool, add: bool) -> Outcome {
// with the selection when it holds elements of the same kind in the same place (same file, same // with the selection when it holds elements of the same kind in the same place (same file, same
// track for segments), otherwise they replace it. // track for segments), otherwise they replace it.
#[wasm_bindgen]
pub fn select_routes(file_id: &str, route_ids_bytes: &[u8], mode: SelectMode) -> Outcome {
match parse_uuid(file_id).zip(uuid_list(route_ids_bytes)) {
Some((file_id, ids)) => run(|e| api::select_routes(e, file_id, &ids, mode.into())),
None => fail("the ids are not UUIDs"),
}
}
#[wasm_bindgen] #[wasm_bindgen]
pub fn select_tracks(file_id: &str, track_ids_bytes: &[u8], mode: SelectMode) -> Outcome { pub fn select_tracks(file_id: &str, track_ids_bytes: &[u8], mode: SelectMode) -> Outcome {
match parse_uuid(file_id).zip(uuid_list(track_ids_bytes)) { match parse_uuid(file_id).zip(uuid_list(track_ids_bytes)) {
+42 -25
View File
@@ -4,7 +4,7 @@
#![allow(clippy::needless_borrows_for_generic_args)] #![allow(clippy::needless_borrows_for_generic_args)]
use js_sys::{Array, BigInt64Array, Float64Array, Object, Reflect}; use js_sys::{Array, Float64Array, Object, Reflect};
use wasm_bindgen::prelude::*; use wasm_bindgen::prelude::*;
use wasm_bindgen_test::*; use wasm_bindgen_test::*;
@@ -16,6 +16,7 @@ use crate::types::*;
const SIMPLE: &[u8] = include_bytes!("../../gpx-rs/data/simple.gpx"); const SIMPLE: &[u8] = include_bytes!("../../gpx-rs/data/simple.gpx");
const WITH_TIME: &[u8] = include_bytes!("../../gpx-rs/data/with_time.gpx"); const WITH_TIME: &[u8] = include_bytes!("../../gpx-rs/data/with_time.gpx");
const WITH_SURFACE: &[u8] = include_bytes!("../../gpx-rs/data/with_surface.gpx"); const WITH_SURFACE: &[u8] = include_bytes!("../../gpx-rs/data/with_surface.gpx");
const WITH_ROUTES: &[u8] = include_bytes!("../../gpx-rs/data/with_routes.gpx");
thread_local! { thread_local! {
static LAST: std::cell::RefCell<JsValue> = const { std::cell::RefCell::new(JsValue::UNDEFINED) }; static LAST: std::cell::RefCell<JsValue> = const { std::cell::RefCell::new(JsValue::UNDEFINED) };
@@ -83,7 +84,7 @@ fn uuid_bytes(ids: &[String]) -> Vec<u8> {
fn test_new_file_is_listed_and_selected() { fn test_new_file_is_listed_and_selected() {
start(); start();
assert!(run(crate::command::files::new_file( assert!(run(crate::command::files::new_file(
"route", None, None, None "route", None, None, None, false
))); )));
assert_eq!(last_error(), None); assert_eq!(last_error(), None);
@@ -112,10 +113,7 @@ fn test_unknown_file_has_no_structure() {
assert!(crate::queries::files::file_structure(&id).is_undefined()); assert!(crate::queries::files::file_structure(&id).is_undefined());
assert!(crate::queries::files::file_structure("not an id").is_undefined()); assert!(crate::queries::files::file_structure("not an id").is_undefined());
assert!(crate::queries::files::file_statistics(&id).is_undefined()); assert!(crate::queries::files::file_statistics(&id).is_undefined());
assert!( assert!(crate::queries::files::export_file(&id, true, true, true, true, true, true).is_none());
crate::queries::files::export_file(&id, true, true, true, true, true, true, false)
.is_none()
);
assert_eq!( assert_eq!(
crate::queries::coordinates::segment_coordinates(&id).length(), crate::queries::coordinates::segment_coordinates(&id).length(),
0 0
@@ -144,7 +142,10 @@ fn test_loaded_file_has_statistics_and_coordinates() {
assert_eq!(crate::queries::statistics::lng().length(), n); assert_eq!(crate::queries::statistics::lng().length(), n);
assert_eq!(crate::queries::statistics::ele().length(), n); assert_eq!(crate::queries::statistics::ele().length(), n);
// no timestamps in this file // no timestamps in this file
assert!(crate::queries::statistics::timestamps().is_none()); let point = crate::queries::statistics::selection_point(0);
assert!(get(&point, "slopeSegmentDistance").as_f64().is_some());
assert!(get(&point, "time").is_undefined());
assert!(crate::queries::statistics::selection_point(n as usize).is_undefined());
let structure = crate::queries::files::file_structure(&id); let structure = crate::queries::files::file_structure(&id);
let segment = get( let segment = get(
@@ -165,8 +166,8 @@ fn test_loaded_file_has_statistics_and_coordinates() {
#[wasm_bindgen_test] #[wasm_bindgen_test]
fn test_timestamps_and_categories() { fn test_timestamps_and_categories() {
started_with(&[(WITH_TIME, "time")]); started_with(&[(WITH_TIME, "time")]);
let times: BigInt64Array = crate::queries::statistics::timestamps().unwrap(); let point = crate::queries::statistics::selection_point(0);
assert!(times.length() > 0); assert!(get(&point, "time").as_f64().unwrap() > 0.0);
assert!(crate::queries::statistics::total_time().is_some()); assert!(crate::queries::statistics::total_time().is_some());
started_with(&[(WITH_SURFACE, "surface")]); started_with(&[(WITH_SURFACE, "surface")]);
@@ -179,6 +180,14 @@ fn test_timestamps_and_categories() {
crate::queries::statistics::surface_starts().length(), crate::queries::statistics::surface_starts().length(),
values.len() as u32 values.len() as u32
); );
// the name of the surface of a trackpoint that has one
let starts = crate::queries::statistics::surface_starts().to_vec();
let known = values.iter().position(|v| *v > 0).unwrap();
let point = crate::queries::statistics::selection_point(starts[known] as usize);
assert_eq!(
get(&point, "surface").as_string(),
Some(surfaces[values[known] as usize - 1].clone())
);
} }
#[wasm_bindgen_test] #[wasm_bindgen_test]
@@ -189,7 +198,9 @@ fn test_the_reason_of_a_failure_can_be_read() {
assert!(!run(crate::command::tools::reverse())); assert!(!run(crate::command::tools::reverse()));
assert_eq!(last_error().as_deref(), Some("nothing to do")); assert_eq!(last_error().as_deref(), Some("nothing to do"));
// the next call starts afresh // the next call starts afresh
assert!(run(crate::command::files::new_file("a", None, None, None))); assert!(run(crate::command::files::new_file(
"a", None, None, None, false
)));
assert_eq!(last_error(), None); assert_eq!(last_error(), None);
// invalid data // invalid data
@@ -242,7 +253,9 @@ fn test_the_reason_of_a_failure_can_be_read() {
assert!(last_error().is_some()); assert!(last_error().is_some());
// answers are not failures // answers are not failures
assert!(run(crate::command::files::new_file("b", None, None, None))); assert!(run(crate::command::files::new_file(
"b", None, None, None, false
)));
assert!(!crate::command::files::can_paste()); assert!(!crate::command::files::can_paste());
assert_eq!(last_error(), None); assert_eq!(last_error(), None);
} }
@@ -251,7 +264,9 @@ fn test_the_reason_of_a_failure_can_be_read() {
fn test_undo_and_redo() { fn test_undo_and_redo() {
started_with(&[]); started_with(&[]);
assert!(!can_undo() && !can_redo()); assert!(!can_undo() && !can_redo());
assert!(run(crate::command::files::new_file("a", None, None, None))); assert!(run(crate::command::files::new_file(
"a", None, None, None, false
)));
assert!(can_undo() && !can_redo()); assert!(can_undo() && !can_redo());
assert!(run(undo())); assert!(run(undo()));
assert!(order().is_empty()); assert!(order().is_empty());
@@ -268,8 +283,7 @@ fn test_export_and_exportable_data() {
let ids = order(); let ids = order();
let bytes = let bytes =
crate::queries::files::export_file(&ids[0], true, true, true, true, true, true, false) crate::queries::files::export_file(&ids[0], true, true, true, true, true, true).unwrap();
.unwrap();
assert!(String::from_utf8(bytes).unwrap().contains("<gpx")); assert!(String::from_utf8(bytes).unwrap().contains("<gpx"));
// the time is the first bit // the time is the first bit
@@ -289,18 +303,17 @@ fn test_export_and_exportable_data() {
} }
#[wasm_bindgen_test] #[wasm_bindgen_test]
fn test_export_as_route() { fn test_export_tracks_and_routes() {
started_with(&[(SIMPLE, "plain")]); started_with(&[(SIMPLE, "track"), (WITH_ROUTES, "routes")]);
let id = order().remove(0); let text = |id: &str| {
let text = |as_route| {
let bytes = let bytes =
crate::queries::files::export_file(&id, true, true, true, true, true, true, as_route) crate::queries::files::export_file(id, true, true, true, true, true, true).unwrap();
.unwrap();
String::from_utf8(bytes).unwrap() String::from_utf8(bytes).unwrap()
}; };
let tracks = text(false); let ids = order();
let tracks = text(&ids[0]);
assert!(tracks.contains("<trk>") && !tracks.contains("<rte>")); assert!(tracks.contains("<trk>") && !tracks.contains("<rte>"));
let routes = text(true); let routes = text(&ids[1]);
assert!(routes.contains("<rte>") && !routes.contains("<trk>")); assert!(routes.contains("<rte>") && !routes.contains("<trk>"));
assert!(routes.contains("<rtept")); assert!(routes.contains("<rtept"));
} }
@@ -379,7 +392,7 @@ fn test_move_elements_takes_js_objects() {
fn test_waypoints() { fn test_waypoints() {
started_with(&[]); started_with(&[]);
assert!(run(crate::command::files::new_file( assert!(run(crate::command::files::new_file(
"file", None, None, None "file", None, None, None, false
))); )));
let file = order().remove(0); let file = order().remove(0);
assert!(run(crate::command::waypoints::new_waypoint( assert!(run(crate::command::waypoints::new_waypoint(
@@ -483,7 +496,9 @@ fn test_editing_tools_change_the_statistics() {
#[wasm_bindgen_test] #[wasm_bindgen_test]
fn test_last_update_describes_the_last_call_only() { fn test_last_update_describes_the_last_call_only() {
started_with(&[]); started_with(&[]);
assert!(run(crate::command::files::new_file("a", None, None, None))); assert!(run(crate::command::files::new_file(
"a", None, None, None, false
)));
let update = last_outcome(); let update = last_outcome();
assert_eq!(array(get(&update, "added")).length(), 1); assert_eq!(array(get(&update, "added")).length(), 1);
assert_eq!(get(&update, "orderChanged").as_bool(), Some(true)); assert_eq!(get(&update, "orderChanged").as_bool(), Some(true));
@@ -495,7 +510,9 @@ fn test_last_update_describes_the_last_call_only() {
assert_eq!(get(&update, "orderChanged").as_bool(), Some(false)); assert_eq!(get(&update, "orderChanged").as_bool(), Some(false));
assert_eq!(get(&update, "selectionChanged").as_bool(), Some(false)); assert_eq!(get(&update, "selectionChanged").as_bool(), Some(false));
assert!(run(crate::command::files::new_file("b", None, None, None))); assert!(run(crate::command::files::new_file(
"b", None, None, None, false
)));
assert!(!run(crate::command::tools::crop(3, 1))); assert!(!run(crate::command::tools::crop(3, 1)));
let update = last_outcome(); let update = last_outcome();
assert_eq!(array(get(&update, "added")).length(), 0); assert_eq!(array(get(&update, "added")).length(), 0);
+37 -5
View File
@@ -28,16 +28,19 @@ const FILE_STRUCTURE_TS: &str = r#"
export type Selection = export type Selection =
| { type: 'empty' } | { type: 'empty' }
| { type: 'file'; fileIds: string[] } | { type: 'file'; fileIds: string[] }
| { type: 'route'; fileId: string; routeIds: string[] }
| { type: 'track'; fileId: string; trackIds: string[] } | { type: 'track'; fileId: string; trackIds: string[] }
| { type: 'segment'; fileId: string; trackId: string; segmentIds: string[] } | { type: 'segment'; fileId: string; trackId: string; segmentIds: string[] }
| { type: 'waypoints'; fileId: string } | { type: 'waypoints'; fileId: string }
| { type: 'waypoint'; fileId: string; waypointIds: string[] }; | { type: 'waypoint'; fileId: string; waypointIds: string[] };
/** /**
* Where moved elements go: a list of the file tree and the position in it, counted among the * Where moved elements go: a list of the file tree and the position in it, counted among the
* elements of the list that are not moved. * elements of the list that are not moved. Routes and tracks keep their kind: moved among the
* other kind, routes go after the routes, tracks before the tracks.
*/ */
export type MoveTarget = export type MoveTarget =
| { type: 'files'; index: number } | { type: 'files'; index: number }
| { type: 'routes'; fileId: string; index: number }
| { type: 'tracks'; fileId: string; index: number } | { type: 'tracks'; fileId: string; index: number }
| { type: 'segments'; fileId: string; trackId: string; index: number } | { type: 'segments'; fileId: string; trackId: string; index: number }
| { type: 'waypoints'; fileId: string; index: number }; | { type: 'waypoints'; fileId: string; index: number };
@@ -64,9 +67,26 @@ export interface TrackpointDetails {
/** ms since epoch */ /** ms since epoch */
time?: number; time?: number;
} }
/**
* A trackpoint of the selection: what the statistics buffers do not hold for each trackpoint.
* Absent fields are unknown.
*/
export interface SelectionPoint {
/** Slope (%) at the trackpoint. */
slope?: number;
/** ms since epoch */
time?: number;
/** Slope (%) and length (km) of the smoothed elevation segment of the trackpoint. */
slopeSegmentSlope?: number;
slopeSegmentDistance?: number;
surface?: string;
highway?: string;
sacScale?: string;
mtbScale?: string;
}
/** What was copied or cut, to be pasted: the kind of the elements and their ids. */ /** What was copied or cut, to be pasted: the kind of the elements and their ids. */
export interface Clipboard { export interface Clipboard {
type: 'files' | 'tracks' | 'segments' | 'waypoints'; type: 'files' | 'routes' | 'tracks' | 'segments' | 'waypoints';
ids: string[]; ids: string[];
cut: boolean; cut: boolean;
} }
@@ -102,6 +122,8 @@ export interface FileStructure {
id: string; id: string;
name: string; name: string;
desc?: string; desc?: string;
/** The routes come before the tracks, in the file and in the statistics. */
routes: RouteNode[];
tracks: TrackNode[]; tracks: TrackNode[];
waypoints: WaypointNode[]; waypoints: WaypointNode[];
/** Changes when the waypoints of the file change: refetch their coordinates. */ /** Changes when the waypoints of the file change: refetch their coordinates. */
@@ -129,14 +151,22 @@ export interface GlobalStatistics {
/** Absent when there are no trackpoints. */ /** Absent when there are no trackpoints. */
bounds?: { west: number; south: number; east: number; north: number }; bounds?: { west: number; south: number; east: number; north: number };
} }
export interface TrackNode { /** The name, description and style of a route or a track. */
id: string; export interface PathInfo {
name?: string; name?: string;
desc?: string; desc?: string;
/** Style of the track, only present when the file defines it. */ /** Style, only present when the file defines it. */
color?: string; color?: string;
opacity?: number; opacity?: number;
width?: number; width?: number;
}
/** A route: like a track, with a single segment. */
export interface RouteNode extends PathInfo {
id: string;
segment: SegmentNode;
}
export interface TrackNode extends PathInfo {
id: string;
segments: SegmentNode[]; segments: SegmentNode[];
} }
export interface SegmentNode { export interface SegmentNode {
@@ -161,6 +191,8 @@ extern "C" {
pub type WaypointDetails; pub type WaypointDetails;
#[wasm_bindgen(typescript_type = "TrackpointDetails | undefined")] #[wasm_bindgen(typescript_type = "TrackpointDetails | undefined")]
pub type TrackpointDetails; pub type TrackpointDetails;
#[wasm_bindgen(typescript_type = "SelectionPoint | undefined")]
pub type SelectionPoint;
#[wasm_bindgen(typescript_type = "MoveTarget")] #[wasm_bindgen(typescript_type = "MoveTarget")]
pub type MoveTarget; pub type MoveTarget;
+2
View File
@@ -15,6 +15,7 @@ pub enum MergeType {
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub enum SplitType { pub enum SplitType {
Files, Files,
Routes,
Tracks, Tracks,
Segments, Segments,
} }
@@ -23,6 +24,7 @@ impl From<SplitType> for engine::SplitType {
fn from(t: SplitType) -> Self { fn from(t: SplitType) -> Self {
match t { match t {
SplitType::Files => Self::Files, SplitType::Files => Self::Files,
SplitType::Routes => Self::Routes,
SplitType::Tracks => Self::Tracks, SplitType::Tracks => Self::Tracks,
SplitType::Segments => Self::Segments, SplitType::Segments => Self::Segments,
} }
+89 -67
View File
@@ -18,7 +18,7 @@
"gpx-wasm": "file:../wasm/pkg", "gpx-wasm": "file:../wasm/pkg",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"mapillary-js": "^4.1.2", "mapillary-js": "^4.1.2",
"maplibre-gl": "^5.21.1", "maplibre-gl": "^6.15.0",
"sanitize-html": "^2.17.0", "sanitize-html": "^2.17.0",
"sortablejs": "^1.15.6" "sortablejs": "^1.15.6"
}, },
@@ -1695,13 +1695,18 @@
} }
}, },
"node_modules/@mapbox/jsonlint-lines-primitives": { "node_modules/@mapbox/jsonlint-lines-primitives": {
"version": "2.0.2", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz",
"integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==",
"license": "MIT",
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 22"
} }
}, },
"node_modules/@mapbox/point-geometry": { "node_modules/@mapbox/point-geometry": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/@mapbox/sphericalmercator": { "node_modules/@mapbox/sphericalmercator": {
@@ -1712,24 +1717,32 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@mapbox/tiny-sdf": { "node_modules/@mapbox/tiny-sdf": {
"version": "2.0.7", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.3.0.tgz",
"integrity": "sha512-gBvcNDBBB66bgQYvnaJ6Jk+VCaMmVq5+IZR2kWPisT88Z8s0kBQt6hfVi8QgRW8Vpg0UWZzBA4ZSyzHHC+VeRA==",
"license": "BSD-2-Clause" "license": "BSD-2-Clause"
}, },
"node_modules/@mapbox/unitbezier": { "node_modules/@mapbox/unitbezier": {
"version": "0.0.1", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
"license": "BSD-2-Clause" "license": "BSD-2-Clause"
}, },
"node_modules/@mapbox/vector-tile": { "node_modules/@mapbox/vector-tile": {
"version": "2.0.4", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz",
"integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"@mapbox/point-geometry": "~1.1.0", "@mapbox/point-geometry": "~1.1.0",
"@types/geojson": "^7946.0.16", "@types/geojson": "^7946.0.16",
"pbf": "^4.0.1" "pbf": "^5.0.0"
} }
}, },
"node_modules/@mapbox/vector-tile/node_modules/pbf": { "node_modules/@mapbox/vector-tile/node_modules/pbf": {
"version": "4.0.1", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz",
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"resolve-protobuf-schema": "^2.1.0" "resolve-protobuf-schema": "^2.1.0"
@@ -1738,18 +1751,13 @@
"pbf": "bin/pbf" "pbf": "bin/pbf"
} }
}, },
"node_modules/@mapbox/whoots-js": {
"version": "3.1.0",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@maplibre/geojson-vt": { "node_modules/@maplibre/geojson-vt": {
"version": "6.0.4", "version": "6.3.0",
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.3.0.tgz",
"integrity": "sha512-3Zw2jsU9pIJG3G2CQFbLjWj2MlAHodhD7cSY+15iywUuLmmkgzcKRnueVRZ4AcC/5wOtfhidcJAAzBpoBNs3Xg==",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"kdbush": "^4.0.2" "kdbush": "^4.1.0"
} }
}, },
"node_modules/@maplibre/maplibre-gl-geocoder": { "node_modules/@maplibre/maplibre-gl-geocoder": {
@@ -1770,15 +1778,16 @@
} }
}, },
"node_modules/@maplibre/maplibre-gl-style-spec": { "node_modules/@maplibre/maplibre-gl-style-spec": {
"version": "24.8.1", "version": "26.4.4",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.4.4.tgz",
"integrity": "sha512-hG4RZR4PBd/+6A3ODdZ3AnQRF106ZQk1y3bhwDg8bH/A4Oz2tnRUJVVWs9BZa6gXZ1d3UKe89C+Mm+olGBELvA==",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@mapbox/jsonlint-lines-primitives": "~2.0.2", "@mapbox/jsonlint-lines-primitives": "^2.0.3",
"@mapbox/unitbezier": "^0.0.1", "@mapbox/unitbezier": "^1.0.0",
"json-stringify-pretty-compact": "^4.0.0", "json-stringify-pretty-compact": "^4.0.0",
"minimist": "^1.2.8", "minimist": "^1.2.8",
"quickselect": "^3.0.0", "quickselect": "^3.0.0",
"rw": "^1.3.3",
"tinyqueue": "^3.0.0" "tinyqueue": "^3.0.0"
}, },
"bin": { "bin": {
@@ -1788,31 +1797,29 @@
} }
}, },
"node_modules/@maplibre/mlt": { "node_modules/@maplibre/mlt": {
"version": "1.1.8", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.3.0.tgz",
"integrity": "sha512-7M2O7ABM80Oi8XPkfkrjKB8mOBIxGoVgkA52Mu4RMWoBvvMdagQ522+WxVYujZsMpqIr3BpzrTbXrm4k8lCzCQ==",
"license": "(MIT OR Apache-2.0)", "license": "(MIT OR Apache-2.0)",
"dependencies": { "dependencies": {
"@mapbox/point-geometry": "^1.1.0" "@mapbox/point-geometry": "^1.1.0"
} }
}, },
"node_modules/@maplibre/vt-pbf": { "node_modules/@maplibre/vt-pbf": {
"version": "4.3.0", "version": "4.3.2",
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz",
"integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@mapbox/point-geometry": "^1.1.0", "@mapbox/point-geometry": "^1.1.0",
"@mapbox/vector-tile": "^2.0.4",
"@maplibre/geojson-vt": "^5.0.4",
"@types/geojson": "^7946.0.16", "@types/geojson": "^7946.0.16",
"@types/supercluster": "^7.1.3", "pbf": "^5.1.0"
"pbf": "^4.0.1",
"supercluster": "^8.0.1"
} }
}, },
"node_modules/@maplibre/vt-pbf/node_modules/@maplibre/geojson-vt": {
"version": "5.0.4",
"license": "ISC"
},
"node_modules/@maplibre/vt-pbf/node_modules/pbf": { "node_modules/@maplibre/vt-pbf/node_modules/pbf": {
"version": "4.0.1", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz",
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"resolve-protobuf-schema": "^2.1.0" "resolve-protobuf-schema": "^2.1.0"
@@ -2758,13 +2765,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/supercluster": {
"version": "7.1.3",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/three": { "node_modules/@types/three": {
"version": "0.134.0", "version": "0.134.0",
"license": "MIT" "license": "MIT"
@@ -3098,6 +3098,15 @@
"node": "18 || 20 || >=22" "node": "18 || 20 || >=22"
} }
}, },
"node_modules/bidi-js": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz",
"integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==",
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/bits-ui": { "node_modules/bits-ui": {
"version": "2.17.2", "version": "2.17.2",
"dev": true, "dev": true,
@@ -4177,6 +4186,8 @@
}, },
"node_modules/json-stringify-pretty-compact": { "node_modules/json-stringify-pretty-compact": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/jszip": { "node_modules/jszip": {
@@ -4190,7 +4201,9 @@
} }
}, },
"node_modules/kdbush": { "node_modules/kdbush": {
"version": "4.0.2", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
"integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/keyv": { "node_modules/keyv": {
@@ -4611,25 +4624,26 @@
"license": "(MIT AND Zlib)" "license": "(MIT AND Zlib)"
}, },
"node_modules/maplibre-gl": { "node_modules/maplibre-gl": {
"version": "5.22.0", "version": "6.15.0",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.15.0.tgz",
"integrity": "sha512-kxFetbvUHDOVaAP1CYb226Mdk2QhzIPddqM0xJTFD35wu78Mpd6NM7+H68ALjc6Oa0kAOX5EAdMKyvD2u3qfcg==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
"@mapbox/point-geometry": "^1.1.0", "@mapbox/point-geometry": "^1.1.0",
"@mapbox/tiny-sdf": "^2.0.7", "@mapbox/tiny-sdf": "^2.3.0",
"@mapbox/unitbezier": "^0.0.1", "@mapbox/unitbezier": "^1.0.0",
"@mapbox/vector-tile": "^2.0.4", "@mapbox/vector-tile": "^3.0.0",
"@mapbox/whoots-js": "^3.1.0", "@maplibre/geojson-vt": "^6.3.0",
"@maplibre/geojson-vt": "^6.0.4", "@maplibre/maplibre-gl-style-spec": "^26.4.4",
"@maplibre/maplibre-gl-style-spec": "^24.8.1", "@maplibre/mlt": "^1.3.0",
"@maplibre/mlt": "^1.1.8", "@maplibre/vt-pbf": "^4.3.2",
"@maplibre/vt-pbf": "^4.3.0",
"@types/geojson": "^7946.0.16", "@types/geojson": "^7946.0.16",
"earcut": "^3.0.2", "bidi-js": "^1.1.0",
"earcut": "^3.2.4",
"gl-matrix": "^3.4.4", "gl-matrix": "^3.4.4",
"kdbush": "^4.0.2", "kdbush": "^4.1.0",
"murmurhash-js": "^1.0.0", "murmurhash-js": "^1.0.0",
"pbf": "^4.0.1", "pbf": "^5.1.2",
"potpack": "^2.1.0", "potpack": "^2.1.0",
"quickselect": "^3.0.0", "quickselect": "^3.0.0",
"tinyqueue": "^3.0.0" "tinyqueue": "^3.0.0"
@@ -4643,11 +4657,15 @@
} }
}, },
"node_modules/maplibre-gl/node_modules/earcut": { "node_modules/maplibre-gl/node_modules/earcut": {
"version": "3.0.2", "version": "3.2.4",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.4.tgz",
"integrity": "sha512-FHc6yNpnTORB0tM5kmZ03iD2mDuNakDE2lvf4Hw1XkfxtEHIGVi01B/GqUSwx+NcC1WGjidAfpTXzeALtPtfpg==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/maplibre-gl/node_modules/pbf": { "node_modules/maplibre-gl/node_modules/pbf": {
"version": "4.0.1", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz",
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"resolve-protobuf-schema": "^2.1.0" "resolve-protobuf-schema": "^2.1.0"
@@ -4708,6 +4726,8 @@
}, },
"node_modules/minimist": { "node_modules/minimist": {
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
@@ -5256,6 +5276,8 @@
}, },
"node_modules/quickselect": { "node_modules/quickselect": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/rbush": { "node_modules/rbush": {
@@ -5294,6 +5316,15 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve-from": { "node_modules/resolve-from": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -5391,10 +5422,6 @@
} }
} }
}, },
"node_modules/rw": {
"version": "1.3.3",
"license": "BSD-3-Clause"
},
"node_modules/rxjs": { "node_modules/rxjs": {
"version": "7.8.2", "version": "7.8.2",
"license": "Apache-2.0", "license": "Apache-2.0",
@@ -5628,13 +5655,6 @@
"xtend": "^4.0.0" "xtend": "^4.0.0"
} }
}, },
"node_modules/supercluster": {
"version": "8.0.1",
"license": "ISC",
"dependencies": {
"kdbush": "^4.0.2"
}
},
"node_modules/supports-color": { "node_modules/supports-color": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -5876,6 +5896,8 @@
}, },
"node_modules/tinyqueue": { "node_modules/tinyqueue": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/totalist": { "node_modules/totalist": {
+2 -1
View File
@@ -5,6 +5,7 @@
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"build:desktop": "BUILD_TARGET=desktop BASE_PATH='' vite build",
"prebuild": "npx tsx src/lib/scripts/pwa-manifest.ts", "prebuild": "npx tsx src/lib/scripts/pwa-manifest.ts",
"postbuild": "npx tsx src/lib/scripts/sitemap.ts", "postbuild": "npx tsx src/lib/scripts/sitemap.ts",
"preview": "vite preview", "preview": "vite preview",
@@ -77,7 +78,7 @@
"gpx-wasm": "file:../wasm/pkg", "gpx-wasm": "file:../wasm/pkg",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"mapillary-js": "^4.1.2", "mapillary-js": "^4.1.2",
"maplibre-gl": "^5.21.1", "maplibre-gl": "^6.15.0",
"sanitize-html": "^2.17.0", "sanitize-html": "^2.17.0",
"sortablejs": "^1.15.6" "sortablejs": "^1.15.6"
} }
+6 -1
View File
@@ -46,8 +46,13 @@ export async function handle({ event, resolve }) {
<meta name="twitter:url" content="https://gpx.studio/" /> <meta name="twitter:url" content="https://gpx.studio/" />
<meta name="twitter:site" content="@gpxstudio" /> <meta name="twitter:site" content="@gpxstudio" />
<meta name="twitter:creator" content="@gpxstudio" /> <meta name="twitter:creator" content="@gpxstudio" />
<link rel="alternate" hreflang="x-default" href="https://gpx.studio${getURLForLanguage('en', path)}" /> <link rel="alternate" hreflang="x-default" href="https://gpx.studio${getURLForLanguage('en', path)}" />`;
// the manifests are made for the website, not for the desktop app (`npm run build:desktop`)
if (process.env.BUILD_TARGET !== 'desktop') {
headTag += `
<link rel="manifest" href="/${language}.manifest.webmanifest" />`; <link rel="manifest" href="/${language}.manifest.webmanifest" />`;
}
if (page !== '404') { if (page !== '404') {
for (let lang of Object.keys(languages)) { for (let lang of Object.keys(languages)) {
+108 -42
View File
@@ -4,8 +4,8 @@ import { get } from 'svelte/store';
import { boundsManager } from '$lib/state/bounds'; import { boundsManager } from '$lib/state/bounds';
import { engine } from '$lib/engine'; import { engine } from '$lib/engine';
import { defaultFileName } from '$lib/actions/default-file-name'; import { defaultFileName } from '$lib/actions/default-file-name';
import { askUnsaved } from '$lib/components/file-list/unsaved-prompt.svelte'; import { askOverwrite, askUnsaved } from '$lib/components/file-list/unsaved-prompt.svelte';
import { subfolder } from '$lib/engine/workspace'; import { toast } from 'svelte-sonner';
import type { UnsavedChoice } from '$lib/engine'; import type { UnsavedChoice } from '$lib/engine';
/** The name of a new file: the translated default name, numbered if other files have it. */ /** The name of a new file: the translated default name, numbered if other files have it. */
@@ -20,6 +20,12 @@ export function createFile() {
currentTool.set(Tool.ROUTING); currentTool.set(Tool.ROUTING);
} }
/** Creates a file that starts with an empty route, which the routing tool then draws. */
export function createRouteFile() {
engine.newFile(newFileName(), undefined, true);
currentTool.set(Tool.ROUTING);
}
/** Asks for the files to open: with the file picker of the system in the desktop app. */ /** Asks for the files to open: with the file picker of the system in the desktop app. */
export async function openFiles() { export async function openFiles() {
if (get(engine.workspace)) { if (get(engine.workspace)) {
@@ -32,71 +38,131 @@ export async function openFiles() {
} }
} }
/** /** The names of files, to show them to the user. */
* What to do with the files of the open folder that have unsaved changes, which the user is asked function namesOf(ids: string[]): string[] {
* when there are some: `undefined` when there are none, `'cancel'` when they do not want to go on.
*/
async function askAboutUnsavedFiles(): Promise<UnsavedChoice | undefined | 'cancel'> {
const workspace = get(engine.workspace);
if (!workspace?.folder) {
return undefined;
}
const files = get(engine.files); const files = get(engine.files);
const names = Object.entries(workspace.files) return ids.map((id) => {
// the files in the folder, and the new ones, which are saved in it
.filter(
([, file]) =>
file.dirty &&
(file.path === undefined || subfolder(workspace.folder, file.path) !== undefined)
)
.map(([id]) => {
const file = files.get(id); const file = files.get(id);
return file ? get(file).structure.name : id; return file ? get(file).structure.name : id;
}); });
return names.length > 0 ? askUnsaved(names) : undefined; }
/** The ids among `ids` of the files that have changes that are not saved. */
function unsavedAmong(ids: string[]): string[] {
const workspace = get(engine.workspace);
return ids.filter((id) => workspace?.files[id]?.dirty);
} }
/** /**
* Opens a folder, which is watched (the desktop app). The folder that was open is closed: the user * What to do with the files among `ids` that have unsaved changes, which the user is asked when
* is asked what to do with its unsaved files. * there are some: `undefined` when there are none, `'cancel'` when they do not want to go on.
*/
async function askAboutUnsavedFiles(ids: string[]): Promise<UnsavedChoice | undefined | 'cancel'> {
const unsaved = unsavedAmong(ids);
return unsaved.length > 0 ? askUnsaved(namesOf(unsaved)) : undefined;
}
/** All the open files. */
function allFileIds(): string[] {
return get(engine.order);
}
/**
* Opens a folder, which is watched (the desktop app). Its files are not opened, they are in its
* tree. The folder that was open is closed with its files: the user is asked what to do with the
* unsaved ones.
*/ */
export async function openFolder() { export async function openFolder() {
const unsaved = await askAboutUnsavedFiles(); const unsaved = await askAboutUnsavedFiles(allFileIds());
if (unsaved === 'cancel') { if (unsaved !== 'cancel') {
return; await engine.openFolder(unsaved);
}
const ids = await engine.openFolder(unsaved);
if (ids.length > 0) {
boundsManager.fitBoundsOnLoad(ids);
} }
} }
/** Closes the open folder and its files: the user is asked what to do with the unsaved ones. */ /** Closes the open folder and all the files: the user is asked what to do with the unsaved ones. */
export async function closeFolder() { export async function closeFolder() {
const unsaved = await askAboutUnsavedFiles(); const unsaved = await askAboutUnsavedFiles(allFileIds());
if (unsaved !== 'cancel') { if (unsaved !== 'cancel') {
await engine.closeFolder(unsaved); await engine.closeFolder(unsaved);
} }
} }
/** Closes files: the user is asked what to do with the ones that have unsaved changes. */
export async function closeFiles(ids: string[]) {
if (ids.length === 0) {
return;
}
const unsaved = await askAboutUnsavedFiles(ids);
if (unsaved !== 'cancel') {
await engine.closeFiles(ids, unsaved);
}
}
/** Closes all the files. */
export function closeAll() {
return closeFiles(allFileIds());
}
/**
* Writes files to the file system. The user is asked first about the ones whose file was also
* changed on the disk, which saving overwrites: they can take the disk instead of their changes.
*/
async function saveFiles(ids: string[]) {
if (ids.length === 0) {
return;
}
const workspace = get(engine.workspace);
const replaced = ids.filter((id) => workspace?.files[id]?.replaced);
if (replaced.length > 0) {
const choice = await askOverwrite(namesOf(replaced));
if (choice === 'cancel') {
return;
}
if (choice === 'reload') {
// they are the files on the disk now, there is nothing to save
await engine.reload(replaced);
ids = ids.filter((id) => !replaced.includes(id));
}
}
if (ids.length > 0) {
await engine.save(ids);
}
}
/** Saves the selected files to the file system, all the ones that have unsaved edits if none is. */ /** Saves the selected files to the file system, all the ones that have unsaved edits if none is. */
export function saveSelected(selectedFileIds: string[]) { export function saveSelected(selectedFileIds: string[]) {
const workspace = get(engine.workspace); return saveFiles(selectedFileIds.length > 0 ? selectedFileIds : unsavedAmong(allFileIds()));
const ids =
selectedFileIds.length > 0
? selectedFileIds
: Object.keys(workspace?.files ?? {}).filter((id) => workspace?.files[id].dirty);
if (ids.length > 0) {
engine.save(ids);
}
} }
/** Saves all the files that have unsaved edits to the file system. */ /** Saves all the files that have unsaved edits to the file system. */
export function saveAll() { export function saveAll() {
const workspace = get(engine.workspace); return saveFiles(unsavedAmong(allFileIds()));
const ids = Object.keys(workspace?.files ?? {}).filter((id) => workspace?.files[id].dirty); }
/** Saves a file where the user says, which is where it is from then on (not for several files). */
export async function saveAs(selectedFileIds: string[]) {
if (selectedFileIds.length === 1) {
await engine.saveAs(selectedFileIds[0]);
}
}
/**
* Opens files of the file system, like the ones of the tree of the open folder: as a preview
* (`preview`, a single click), which the next preview replaces, or for good. The map fits the files
* that were opened.
*/
export async function openPaths(paths: string[], preview = false) {
const ids = await engine.openPaths(paths, preview);
if (ids.length > 0) { if (ids.length > 0) {
engine.save(ids); boundsManager.fitBoundsOnLoad(ids);
}
}
/** Tells the user why the engine did not do what was asked (a key of the dictionaries, or text). */
export function showEngineError() {
const error = get(engine.lastError);
if (error !== undefined && error !== 'nothing to do') {
toast.error(i18n._(error, error));
} }
} }
+133 -13
View File
@@ -40,11 +40,15 @@
FolderOpen, FolderOpen,
FileStack, FileStack,
FileX, FileX,
Save,
SaveAll,
BookOpenText, BookOpenText,
ChartArea, ChartArea,
Maximize, Maximize,
Maximize2, Maximize2,
Minimize2, Minimize2,
FolderX,
ArrowRightLeft,
} from '@lucide/svelte'; } from '@lucide/svelte';
import { map } from '$lib/components/map/map'; import { map } from '$lib/components/map/map';
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte'; import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
@@ -59,10 +63,23 @@
import { languages } from '$lib/i18n/languages'; import { languages } from '$lib/i18n/languages';
import { getURLForLanguage } from '$lib/utils'; import { getURLForLanguage } from '$lib/utils';
import { settings } from '$lib/state/settings'; import { settings } from '$lib/state/settings';
import { createFile, loadFiles, triggerFileInput } from '$lib/actions/file-actions'; import {
closeAll,
closeFiles,
closeFolder,
createFile,
createRouteFile,
loadFiles,
openFiles,
openFolder,
saveAll,
saveAs,
saveSelected,
} from '$lib/actions/file-actions';
import UnsavedDialog from '$lib/components/file-list/UnsavedDialog.svelte';
import { engine } from '$lib/engine'; import { engine } from '$lib/engine';
import { allHidden } from '$lib/engine/all-hidden'; import { allHidden } from '$lib/engine/all-hidden';
import { selectionSize } from '$lib/engine/selection-helpers'; import { selectedFileIds, selectionSize } from '$lib/engine/selection-helpers';
import { boundsManager } from '$lib/state/bounds'; import { boundsManager } from '$lib/state/bounds';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@@ -82,12 +99,18 @@
routing, routing,
} = settings; } = settings;
const { files, selection, canUndo, canRedo, canPaste } = engine; const { files, selection, canUndo, canRedo, canPaste, workspace } = engine;
let selectionCount = $derived(selectionSize($selection)); let selectionCount = $derived(selectionSize($selection));
let noFiles = $derived($files.size === 0); let noFiles = $derived($files.size === 0);
// metadata and style can be edited for files and tracks only // the desktop app: the files are linked to the file system
let filesOrTracksSelected = $derived($selection.type === 'file' || $selection.type === 'track'); let desktop = $derived($workspace !== undefined);
let selectedIds = $derived(selectedFileIds($selection));
let anyUnsaved = $derived(Object.values($workspace?.files ?? {}).some((file) => file.dirty));
// metadata and style can be edited for files, routes and tracks only
let filesOrTracksSelected = $derived(
$selection.type === 'file' || $selection.type === 'route' || $selection.type === 'track'
);
function switchBasemaps() { function switchBasemaps() {
[$currentBasemap, $previousBasemap] = [$previousBasemap, $currentBasemap]; [$currentBasemap, $previousBasemap] = [$previousBasemap, $currentBasemap];
@@ -138,15 +161,26 @@
<Menubar.Content class="border-none"> <Menubar.Content class="border-none">
<Menubar.Item onclick={createFile}> <Menubar.Item onclick={createFile}>
<Plus size="16" /> <Plus size="16" />
{i18n._('menu.new')} {i18n._('menu.new_file')}
<Shortcut key="+" ctrl={true} /> <Shortcut key="+" ctrl={true} />
</Menubar.Item> </Menubar.Item>
<Menubar.Item onclick={createRouteFile}>
<Plus size="16" />
{i18n._('menu.new_route_file')}
</Menubar.Item>
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item onclick={triggerFileInput}> <Menubar.Item onclick={openFiles}>
<FolderOpen size="16" /> <FolderOpen size="16" />
{i18n._('menu.open')} {i18n._('menu.open')}
<Shortcut key="O" ctrl={true} /> <Shortcut key="O" ctrl={true} />
</Menubar.Item> </Menubar.Item>
{#if desktop}
<Menubar.Item onclick={openFolder}>
<FolderOpen size="16" />
{i18n._('menu.open_folder')}
<Shortcut key="O" ctrl={true} shift={true} />
</Menubar.Item>
{/if}
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item onclick={() => engine.duplicate()} disabled={selectionCount == 0}> <Menubar.Item onclick={() => engine.duplicate()} disabled={selectionCount == 0}>
<Copy size="16" /> <Copy size="16" />
@@ -154,6 +188,46 @@
<Shortcut key="D" ctrl={true} /> <Shortcut key="D" ctrl={true} />
</Menubar.Item> </Menubar.Item>
<Menubar.Separator /> <Menubar.Separator />
{#if desktop}
<Menubar.Item
onclick={() => saveSelected(selectedIds)}
disabled={selectionCount == 0 && !anyUnsaved}
>
<Save size="16" />
{i18n._('menu.save')}
<Shortcut key="S" ctrl={true} />
</Menubar.Item>
<Menubar.Item onclick={saveAll} disabled={!anyUnsaved}>
<Save size="16" />
{i18n._('menu.save_all')}
<Shortcut key="S" ctrl={true} shift={true} />
</Menubar.Item>
<Menubar.Item
onclick={() => saveAs(selectedIds)}
disabled={selectedIds.length !== 1}
>
<SaveAll size="16" />
{i18n._('menu.save_as')}
</Menubar.Item>
<Menubar.Separator />
<Menubar.Item
onclick={() => closeFiles(selectedIds)}
disabled={selectedIds.length === 0}
>
<FileX size="16" />
{i18n._('menu.close')}
<Shortcut key="W" ctrl={true} />
</Menubar.Item>
<Menubar.Item onclick={closeAll} disabled={noFiles}>
<FileX size="16" />
{i18n._('menu.close_all')}
<Shortcut key="W" ctrl={true} shift={true} />
</Menubar.Item>
<Menubar.Item onclick={closeFolder} disabled={!$workspace?.folder}>
<FolderX size="16" />
{i18n._('menu.close_folder')}
</Menubar.Item>
{:else}
<Menubar.Item <Menubar.Item
onclick={() => engine.delete(true)} onclick={() => engine.delete(true)}
disabled={selectionCount == 0} disabled={selectionCount == 0}
@@ -167,6 +241,7 @@
{i18n._('menu.delete_all')} {i18n._('menu.delete_all')}
<Shortcut key="⌫" ctrl={true} shift={true} /> <Shortcut key="⌫" ctrl={true} shift={true} />
</Menubar.Item> </Menubar.Item>
{/if}
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item <Menubar.Item
onclick={() => (exportState.current = ExportState.SELECTION)} onclick={() => (exportState.current = ExportState.SELECTION)}
@@ -174,7 +249,7 @@
> >
<Download size="16" /> <Download size="16" />
{i18n._('menu.export')} {i18n._('menu.export')}
<Shortcut key="S" ctrl={true} /> {#if !desktop}<Shortcut key="S" ctrl={true} />{/if}
</Menubar.Item> </Menubar.Item>
<Menubar.Item <Menubar.Item
onclick={() => (exportState.current = ExportState.ALL)} onclick={() => (exportState.current = ExportState.ALL)}
@@ -182,7 +257,7 @@
> >
<Download size="16" /> <Download size="16" />
{i18n._('menu.export_all')} {i18n._('menu.export_all')}
<Shortcut key="S" ctrl={true} shift={true} /> {#if !desktop}<Shortcut key="S" ctrl={true} shift={true} />{/if}
</Menubar.Item> </Menubar.Item>
</Menubar.Content> </Menubar.Content>
</Menubar.Menu> </Menubar.Menu>
@@ -241,6 +316,13 @@
<Plus size="16" /> <Plus size="16" />
{i18n._('menu.new_track')} {i18n._('menu.new_track')}
</Menubar.Item> </Menubar.Item>
<Menubar.Item
onclick={() => engine.newRoute()}
disabled={selectionCount !== 1}
>
<Plus size="16" />
{i18n._('menu.new_route')}
</Menubar.Item>
{:else if $selection.type === 'track'} {:else if $selection.type === 'track'}
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item <Menubar.Item
@@ -251,6 +333,19 @@
{i18n._('menu.new_segment')} {i18n._('menu.new_segment')}
</Menubar.Item> </Menubar.Item>
{/if} {/if}
{#if $selection.type === 'route' || $selection.type === 'track' || $selection.type === 'segment'}
{#if $selection.type !== 'track'}
<Menubar.Separator />
{/if}
<Menubar.Item onclick={() => engine.convert()}>
<ArrowRightLeft size="16" />
{i18n._(
$selection.type === 'route'
? 'menu.convert_to_track'
: 'menu.convert_to_route'
)}
</Menubar.Item>
{/if}
{/if} {/if}
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item onclick={() => engine.selectAll()} disabled={noFiles}> <Menubar.Item onclick={() => engine.selectAll()} disabled={noFiles}>
@@ -288,12 +383,17 @@
<Shortcut key="V" ctrl={true} /> <Shortcut key="V" ctrl={true} />
</Menubar.Item> </Menubar.Item>
{/if} {/if}
{#if !desktop}
<Menubar.Separator /> <Menubar.Separator />
<Menubar.Item onclick={() => engine.delete()} disabled={selectionCount == 0}> <Menubar.Item
onclick={() => engine.delete()}
disabled={selectionCount == 0}
>
<Trash2 size="16" /> <Trash2 size="16" />
{i18n._('menu.delete')} {i18n._('menu.delete')}
<Shortcut key="⌫" ctrl={true} /> <Shortcut key="⌫" ctrl={true} />
</Menubar.Item> </Menubar.Item>
{/if}
</Menubar.Content> </Menubar.Content>
</Menubar.Menu> </Menubar.Menu>
<Menubar.Menu> <Menubar.Menu>
@@ -498,6 +598,7 @@
</div> </div>
<Export /> <Export />
<UnsavedDialog />
<LayerControlSettings bind:open={layerSettingsOpen} /> <LayerControlSettings bind:open={layerSettingsOpen} />
<svelte:window <svelte:window
@@ -519,8 +620,26 @@
if (e.key === '+' && (e.metaKey || e.ctrlKey)) { if (e.key === '+' && (e.metaKey || e.ctrlKey)) {
createFile(); createFile();
e.preventDefault(); e.preventDefault();
} else if (e.key === 'o' && (e.metaKey || e.ctrlKey)) { } else if ((e.key === 'o' || e.key === 'O') && (e.metaKey || e.ctrlKey)) {
triggerFileInput(); if (desktop && e.shiftKey) {
openFolder();
} else {
openFiles();
}
e.preventDefault();
} else if (desktop && (e.key === 'w' || e.key === 'W') && (e.metaKey || e.ctrlKey)) {
if (e.shiftKey) {
closeAll();
} else {
closeFiles(selectedIds);
}
e.preventDefault();
} else if (desktop && (e.key === 's' || e.key === 'S') && (e.metaKey || e.ctrlKey)) {
if (e.shiftKey) {
saveAll();
} else {
saveSelected(selectedIds);
}
e.preventDefault(); e.preventDefault();
} else if (e.key === 'd' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'd' && (e.metaKey || e.ctrlKey)) {
engine.duplicate(); engine.duplicate();
@@ -557,7 +676,8 @@
} }
e.preventDefault(); e.preventDefault();
} else if ((e.key === 'Backspace' || e.key === 'Delete') && (e.metaKey || e.ctrlKey)) { } else if ((e.key === 'Backspace' || e.key === 'Delete') && (e.metaKey || e.ctrlKey)) {
if (!targetInput) { // the desktop app closes files instead
if (!targetInput && !desktop) {
if (e.shiftKey) { if (e.shiftKey) {
engine.deleteAll(); engine.deleteAll();
} else { } else {
+16
View File
@@ -12,6 +12,19 @@
} = $props(); } = $props();
function handleMouseDown(event: PointerEvent) { function handleMouseDown(event: PointerEvent) {
// the text on either side is not selected while dragging, and the cursor stays the one of
// the resizer when the pointer leaves it
event.preventDefault();
const { style } = document.body;
const previous = {
userSelect: style.userSelect,
webkitUserSelect: style.webkitUserSelect,
cursor: style.cursor,
};
style.userSelect = 'none';
style.webkitUserSelect = 'none';
style.cursor = orientation === 'col' ? 'col-resize' : 'row-resize';
const startX = event.clientX; const startX = event.clientX;
const startY = event.clientY; const startY = event.clientY;
const startAfter = after; const startAfter = after;
@@ -32,10 +45,13 @@
const handleMouseUp = () => { const handleMouseUp = () => {
window.removeEventListener('pointermove', handleMouseMove); window.removeEventListener('pointermove', handleMouseMove);
window.removeEventListener('pointerup', handleMouseUp); window.removeEventListener('pointerup', handleMouseUp);
window.removeEventListener('pointercancel', handleMouseUp);
Object.assign(style, previous);
}; };
window.addEventListener('pointermove', handleMouseMove); window.addEventListener('pointermove', handleMouseMove);
window.addEventListener('pointerup', handleMouseUp); window.addEventListener('pointerup', handleMouseUp);
window.addEventListener('pointercancel', handleMouseUp);
} }
</script> </script>
@@ -1,28 +1,48 @@
<script lang="ts"> <script lang="ts">
import { setContext, type Snippet } from 'svelte'; import { getContext, setContext, type Snippet } from 'svelte';
import { CollapsibleTreeState } from './utils.svelte'; import { CollapsibleTreeState, StuckRows } from './utils.svelte';
import { stickyKey, type StickyLevel } from './sticky';
const { const {
defaultState = 'open', defaultState = 'open',
side = 'right', side = 'right',
nohover = false, nohover = false,
slotInsideTrigger = true, slotInsideTrigger = true,
sticky = false,
treeState,
children, children,
}: { }: {
defaultState?: 'open' | 'closed'; defaultState?: 'open' | 'closed';
side?: 'left' | 'right'; side?: 'left' | 'right';
nohover?: boolean; nohover?: boolean;
slotInsideTrigger?: boolean; slotInsideTrigger?: boolean;
/**
* The rows of the nodes stick to the top of the scrolled area while some of the content of
* their node is in view, each under the rows of the nodes it is in. A tree in a tree is
* like its node.
*/
sticky?: boolean;
/** Which nodes are open, to keep it or to open nodes from the outside. */
treeState?: CollapsibleTreeState;
children: Snippet; children: Snippet;
} = $props(); } = $props();
let open = $state(new CollapsibleTreeState(defaultState)); let open = $state(treeState ?? new CollapsibleTreeState(defaultState));
setContext('collapsible-tree-state', open); setContext('collapsible-tree-state', open);
setContext('collapsible-tree-side', side); setContext('collapsible-tree-side', side);
setContext('collapsible-tree-nohover', nohover); setContext('collapsible-tree-nohover', nohover);
setContext('collapsible-tree-parent-id', 'root'); setContext('collapsible-tree-parent-id', 'root');
setContext('collapsible-tree-slot-inside-trigger', slotInsideTrigger); setContext('collapsible-tree-slot-inside-trigger', slotInsideTrigger);
setContext<StickyLevel>(
stickyKey,
getContext<StickyLevel | undefined>(stickyKey) ?? {
enabled: sticky,
top: 0,
depth: 0,
stuckRows: new StuckRows(),
}
);
</script> </script>
{@render children()} {@render children()}
@@ -5,31 +5,56 @@
import { getContext, setContext, type Snippet } from 'svelte'; import { getContext, setContext, type Snippet } from 'svelte';
import type { ClassValue } from 'svelte/elements'; import type { ClassValue } from 'svelte/elements';
import type { CollapsibleTreeState } from './utils.svelte'; import type { CollapsibleTreeState } from './utils.svelte';
import { stickyKey, stickyZIndex, trackStuck, type StickyLevel } from './sticky';
const props: { const props: {
id: string | number; id: string | number;
class?: ClassValue; class?: ClassValue;
trigger: Snippet; trigger: Snippet;
content: Snippet; /** Gets whether the node is open, for contents that are only worth building then. */
content: Snippet<[boolean]>;
} = $props(); } = $props();
let state = getContext<CollapsibleTreeState>('collapsible-tree-state'); let treeState = getContext<CollapsibleTreeState>('collapsible-tree-state');
let side = getContext<'left' | 'right'>('collapsible-tree-side'); let side = getContext<'left' | 'right'>('collapsible-tree-side');
let nohover = getContext<boolean>('collapsible-tree-nohover'); let nohover = getContext<boolean>('collapsible-tree-nohover');
let slotInsideTrigger = getContext<boolean>('collapsible-tree-slot-inside-trigger'); let slotInsideTrigger = getContext<boolean>('collapsible-tree-slot-inside-trigger');
let parentId = getContext<string>('collapsible-tree-parent-id'); let parentId = getContext<string>('collapsible-tree-parent-id');
// where the row sticks, under the rows of the nodes this one is in
let level = getContext<StickyLevel>(stickyKey);
let fullId = `${parentId}.${props.id}`; let fullId = `${parentId}.${props.id}`;
setContext('collapsible-tree-parent-id', fullId); setContext('collapsible-tree-parent-id', fullId);
let open = state.get(fullId); let open = treeState.get(fullId);
// the rows of the nodes in this one stick under this row, whatever its height
let rowHeight = $state(0);
setContext<StickyLevel>(stickyKey, {
get enabled() {
return level.enabled;
},
get top() {
return level.top + rowHeight;
},
get depth() {
return level.depth + 1;
},
get stuckRows() {
return level.stuckRows;
},
});
// the row is on top of the content that goes under it
let stuck = $state(false);
// only the deepest row that is stuck has a border: the rows above it run into it
let bordered = $derived(stuck && level.stuckRows.deepest === level.depth);
export function openNode() { export function openNode() {
open.current = true; open.current = true;
} }
</script> </script>
<Collapsible.Root bind:open={open.current} class={props.class}> {#snippet row()}
{#if slotInsideTrigger} {#if slotInsideTrigger}
<Collapsible.Trigger class="w-full"> <Collapsible.Trigger class="w-full">
<Button <Button
@@ -87,7 +112,36 @@
{/if} {/if}
</Button> </Button>
{/if} {/if}
{/snippet}
<Collapsible.Root bind:open={open.current} class={props.class}>
{#if level.enabled}
<!-- sticks while some of the content of the node is in view, with a border when it does -->
<div
class={['sticky bg-background', bordered && 'border-b']}
style="top: {level.top}px; z-index: {stickyZIndex(level.depth)}"
bind:clientHeight={rowHeight}
{@attach (row) => {
const stop = trackStuck(
row,
() => level.top,
(value) => {
stuck = value;
level.stuckRows.set(fullId, level.depth, value);
}
);
return () => {
stop?.();
level.stuckRows.set(fullId, level.depth, false);
};
}}
>
{@render row()}
</div>
{:else}
{@render row()}
{/if}
<Collapsible.Content> <Collapsible.Content>
{@render props.content()} {@render props.content(open.current)}
</Collapsible.Content> </Collapsible.Content>
</Collapsible.Root> </Collapsible.Root>
@@ -0,0 +1,79 @@
import type { StuckRows } from './utils.svelte';
/**
* Where the row of a node of a sticky tree sticks: under the rows of the nodes it is in, which stay
* in view as long as some of their content can be seen.
*/
export type StickyLevel = {
/** Whether the rows stick. */
readonly enabled: boolean;
/** How far from the top of the scrolled area the rows of this level stick. */
readonly top: number;
/** How many nodes the rows of this level are in. */
readonly depth: number;
/** Which rows of the tree are stuck. */
readonly stuckRows: StuckRows;
};
/** The key of the context that holds the `StickyLevel`. */
export const stickyKey = 'collapsible-tree-sticky';
/** The rows of the outer levels are above the ones of the inner levels. */
export function stickyZIndex(depth: number): number {
return Math.max(100 - depth, 1);
}
/** The first ancestor that scrolls (or clips), which a sticky row sticks to. */
function scrollParent(node: HTMLElement): HTMLElement | null {
for (let parent = node.parentElement; parent; parent = parent.parentElement) {
if (getComputedStyle(parent).overflowY !== 'visible') {
return parent;
}
}
return null;
}
/**
* Tells whether a sticky row is stuck, and in view: it is held at `top()` from the top of the
* scrolled area, further down than the beginning of the node it is in. A row that is on its way out
* with the end of its node (pushed up under the row of the node above) is not.
*/
export function trackStuck(
row: HTMLElement,
top: () => number,
onChange: (stuck: boolean) => void
) {
const node = row.parentElement;
const scroller = scrollParent(row);
if (!node || !scroller) {
return;
}
let stuck = false;
let frame = 0;
const check = () => {
frame = 0;
const rowTop = row.getBoundingClientRect().top;
const held = scroller.getBoundingClientRect().top + scroller.clientTop + top();
const now =
rowTop - node.getBoundingClientRect().top > 0.5 && Math.abs(rowTop - held) < 0.5;
if (now !== stuck) {
stuck = now;
onChange(stuck);
}
};
const schedule = () => {
if (!frame) {
frame = requestAnimationFrame(check);
}
};
scroller.addEventListener('scroll', schedule, { passive: true });
// the node changes when it is opened or closed, or its content does
const observer = new ResizeObserver(schedule);
observer.observe(node);
schedule();
return () => {
scroller.removeEventListener('scroll', schedule);
observer.disconnect();
cancelAnimationFrame(frame);
};
}
@@ -28,4 +28,35 @@ export class CollapsibleTreeState {
} }
return this._open[id]; return this._open[id];
} }
/** The node `from` is now `to` (it was renamed or moved): it stays open or closed, and so do the nodes in it. */
move(from: string, to: string) {
for (const id of Object.keys(this._open)) {
if (id === from || id.startsWith(`${from}.`)) {
this._open[to + id.slice(from.length)] = this._open[id];
delete this._open[id];
}
}
}
}
/**
* The rows of a sticky tree that are stuck, by node, with how deep they are: only the deepest one
* gets the border that shows where the rows end.
*/
export class StuckRows {
private rows: Record<string, number> = $state({});
set(id: string, depth: number, stuck: boolean) {
if (stuck) {
this.rows[id] = depth;
} else if (id in this.rows) {
delete this.rows[id];
}
}
/** The depth of the deepest row that is stuck, -1 if none is. */
get deepest(): number {
return Math.max(-1, ...Object.values(this.rows));
}
} }
@@ -24,14 +24,15 @@ import { get, type Readable, type Writable } from 'svelte/store';
import type { Coordinates } from '$lib/geo/coordinates'; import type { Coordinates } from '$lib/geo/coordinates';
import { import {
engine, engine,
NO_TIME, EMPTY_SELECTION_STATISTICS,
type SelectionPoint,
type SelectionStatistics, type SelectionStatistics,
type StatisticsMetric, type StatisticsMetric,
type StatisticsRequest, type StatisticsRequest,
} from '$lib/engine'; } from '$lib/engine';
import type { SlicedStatistics } from '$lib/state/selection-statistics'; import type { SlicedStatistics } from '$lib/state/selection-statistics';
import { mode } from 'mode-watcher'; import { mode } from 'mode-watcher';
import { categoryValues } from '$lib/engine/trackpoint-categories'; import { categoryValue, intervalAt } from '$lib/engine/trackpoint-categories';
import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/data/colors'; import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/data/colors';
const { distanceUnits, velocityUnits, temperatureUnits } = settings; const { distanceUnits, velocityUnits, temperatureUnits } = settings;
@@ -44,20 +45,13 @@ function optional(value: number, convert: (value: number) => number = (v) => v)
return Number.isNaN(value) ? undefined : convert(value); return Number.isNaN(value) ? undefined : convert(value);
} }
/**
* A point of the chart. The rest of the data of the trackpoint is only read when it is hovered
* (see `SelectionStatistics.point`), and the categories of the fill from their intervals.
*/
interface ElevationProfilePoint { interface ElevationProfilePoint {
x: number; x: number;
y: number; y: number;
time?: Date;
slope: {
at: number;
segment: number;
length: number;
};
surface?: string;
highway?: string;
sacScale?: string;
mtbScale?: string;
coordinates: Coordinates;
index: number; index: number;
} }
@@ -69,6 +63,16 @@ export class ElevationProfile {
private _panning = false; private _panning = false;
private _statistics: Readable<SelectionStatistics>; private _statistics: Readable<SelectionStatistics>;
/** The statistics that the chart shows, which the fills read for each of its segments. */
private _data: SelectionStatistics = EMPTY_SELECTION_STATISTICS;
/**
* The hovered trackpoint, and its data once it is read from the engine (`undefined` if the
* statistics changed in the meantime).
*/
private _hovered: { index: number; loaded: boolean; point?: SelectionPoint } | null = null;
private _loadingHovered = false;
/** The last move over the chart, held until the data of its trackpoint is read. */
private _heldMove: MouseEvent | null = null;
private _slicedStatistics: Writable<SlicedStatistics | undefined>; private _slicedStatistics: Writable<SlicedStatistics | undefined>;
private _hoveredPoint: Writable<Coordinates | null>; private _hoveredPoint: Writable<Coordinates | null>;
private _additionalDatasets: Readable<string[]>; private _additionalDatasets: Readable<string[]>;
@@ -197,11 +201,14 @@ export class ElevationProfile {
label: (context: TooltipItem<'line'>) => { label: (context: TooltipItem<'line'>) => {
let point = context.raw as ElevationProfilePoint; let point = context.raw as ElevationProfilePoint;
if (context.datasetIndex === 0) { if (context.datasetIndex === 0) {
if (this._dragging) { this._hoveredPoint.set(
this._hoveredPoint.set(null); this._dragging
} else { ? null
this._hoveredPoint.set(point.coordinates); : {
lat: this._data.lat[point.index],
lng: this._data.lng[point.index],
} }
);
return `${i18n._('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`; return `${i18n._('quantities.elevation')}: ${getElevationWithUnits(point.y, false)}`;
} else if (context.datasetIndex === 1) { } else if (context.datasetIndex === 1) {
return `${get(velocityUnits) === 'speed' ? i18n._('quantities.speed') : i18n._('quantities.pace')}: ${getVelocityWithUnits(point.y, false)}`; return `${get(velocityUnits) === 'speed' ? i18n._('quantities.speed') : i18n._('quantities.pace')}: ${getVelocityWithUnits(point.y, false)}`;
@@ -219,28 +226,42 @@ export class ElevationProfile {
let context = contexts.filter((context) => context.datasetIndex === 0); let context = contexts.filter((context) => context.datasetIndex === 0);
if (context.length === 0) return; if (context.length === 0) return;
let point = context[0].raw as ElevationProfilePoint; let point = context[0].raw as ElevationProfilePoint;
let slope = { // the rest of the data is there once it is read (see `hover`)
at: point.slope.at.toFixed(1), const hovered = this._hovered;
segment: point.slope.segment.toFixed(1), const details =
length: getDistanceWithUnits(point.slope.length), hovered?.index === point.index ? hovered.point : undefined;
}; const elevationFill = get(this._elevationFill);
let surface = point.surface ?? 'unknown';
let highway = point.highway ?? 'unknown';
let sacScale = point.sacScale;
let mtbScale = point.mtbScale;
let labels = [ let labels = [
` ${i18n._('quantities.distance')}: ${getDistanceWithUnits(point.x, false)}`, ` ${i18n._('quantities.distance')}: ${getDistanceWithUnits(point.x, false)}`,
` ${i18n._('quantities.slope')}: ${slope.at} %${get(this._elevationFill) === 'slope' ? ` (${slope.length} @${slope.segment} %)` : ''}`,
]; ];
if (get(this._elevationFill) === 'surface') { if (!details) {
return labels;
}
if (details.slope !== undefined) {
let slope = `${details.slope.toFixed(1)} %`;
if (
elevationFill === 'slope' &&
details.slopeSegmentSlope !== undefined &&
details.slopeSegmentDistance !== undefined
) {
slope += ` (${getDistanceWithUnits(details.slopeSegmentDistance)} @${details.slopeSegmentSlope.toFixed(1)} %)`;
}
labels.push(` ${i18n._('quantities.slope')}: ${slope}`);
}
let surface = details.surface ?? 'unknown';
let highway = details.highway ?? 'unknown';
let sacScale = details.sacScale;
let mtbScale = details.mtbScale;
if (elevationFill === 'surface') {
labels.push( labels.push(
` ${i18n._('quantities.surface')}: ${i18n._(`toolbar.routing.surface.${surface}`)}` ` ${i18n._('quantities.surface')}: ${i18n._(`toolbar.routing.surface.${surface}`)}`
); );
} }
if (get(this._elevationFill) === 'highway') { if (elevationFill === 'highway') {
labels.push( labels.push(
` ${i18n._('quantities.highway')}: ${i18n._(`toolbar.routing.highway.${highway}`)}${ ` ${i18n._('quantities.highway')}: ${i18n._(`toolbar.routing.highway.${highway}`)}${
sacScale sacScale
@@ -255,9 +276,9 @@ export class ElevationProfile {
} }
} }
if (point.time) { if (details.time !== undefined) {
labels.push( labels.push(
` ${i18n._('quantities.time')}: ${i18n.df.format(point.time)}` ` ${i18n._('quantities.time')}: ${i18n.df.format(new Date(details.time))}`
); );
} }
@@ -339,10 +360,32 @@ export class ElevationProfile {
events: ['mouseout'], events: ['mouseout'],
afterEvent: (chart: Chart, args: { event: ChartEvent }) => { afterEvent: (chart: Chart, args: { event: ChartEvent }) => {
if (args.event.type === 'mouseout') { if (args.event.type === 'mouseout') {
this._hovered = null;
this._heldMove = null;
this._hoveredPoint.set(null); this._hoveredPoint.set(null);
} }
}, },
}, },
{
// The tooltip only shows a trackpoint once all its data is read: a move to
// another one is held until then, and replayed (see `loadHovered`).
id: 'waitForTrackpoint',
events: ['mousemove'],
beforeEvent: (chart: Chart, args: { event: ChartEvent }) => {
const index = this.indexAt(args.event);
if (index === undefined) {
return;
}
const hovered = this._hovered;
if (hovered?.index === index && hovered.loaded) {
this._heldMove = null;
return;
}
this._heldMove = args.event.native as MouseEvent;
this.hover(index);
return false;
},
},
], ],
}); });
@@ -430,6 +473,9 @@ export class ElevationProfile {
return; return;
} }
const data = get(this._statistics); const data = get(this._statistics);
this._data = data;
// the indices of the trackpoints are the ones of other statistics
this._hovered = null;
const units = { const units = {
distance: get(distanceUnits), distance: get(distanceUnits),
velocity: get(velocityUnits), velocity: get(velocityUnits),
@@ -438,28 +484,11 @@ export class ElevationProfile {
const datasets: Array<Array<any>> = [[], [], [], [], [], []]; const datasets: Array<Array<any>> = [[], [], [], [], [], []];
const { global } = data; const { global } = data;
const surfaces = categoryValues(data.surface, data.length);
const highways = categoryValues(data.highway, data.length);
const sacScales = categoryValues(data.sacScale, data.length);
const mtbScales = categoryValues(data.mtbScale, data.length);
for (let index = 0; index < data.length; index++) { for (let index = 0; index < data.length; index++) {
const x = getConvertedDistance(data.totalDistance[index], units.distance); const x = getConvertedDistance(data.totalDistance[index], units.distance);
const ele = data.ele[index];
const timestamp = data.timestamps?.[index] ?? NO_TIME;
datasets[0].push({ datasets[0].push({
x, x,
y: getConvertedElevation(ele, units.distance), y: getConvertedElevation(data.ele[index], units.distance),
time: timestamp === NO_TIME ? undefined : new Date(Number(timestamp)),
slope: {
at: data.slope[index],
segment: data.slopeSegmentSlope?.[index] ?? NaN,
length: data.slopeSegmentDistance?.[index] ?? NaN,
},
surface: surfaces[index],
highway: highways[index],
sacScale: sacScales[index],
mtbScale: mtbScales[index],
coordinates: { lat: data.lat[index], lng: data.lng[index] },
index, index,
}); });
if (data.speed && (global.totalTime ?? 0) > 0) { if (data.speed && (global.totalTime ?? 0) > 0) {
@@ -643,20 +672,83 @@ export class ElevationProfile {
} }
} }
slopeFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) { /** The trackpoint that the tooltip shows for an event, `undefined` outside of the chart. */
const point = context.p0.raw as ElevationProfilePoint; private indexAt(event: ChartEvent): number | undefined {
return getSlopeColor(point.slope.segment); const elements = this._chart?.getElementsAtEventForMode(
event as unknown as Event,
'nearest',
{ axis: 'x', intersect: false },
false
);
const element = elements?.find((element) => element.datasetIndex === 0);
// the data of the chart can be decimated: its points are the ones of the elements
const point = element && this._chart?.data.datasets[0].data[element.index];
return (point as ElevationProfilePoint | undefined)?.index;
} }
surfaceFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) { /**
const point = context.p0.raw as ElevationProfilePoint; * Reads the data of the hovered trackpoint from the engine, for the tooltip. One trackpoint is
return getSurfaceColor(point.surface ?? ''); * read at a time, the last one hovered.
*/
hover(index: number) {
if (this._hovered?.index === index) {
return;
}
this._hovered = { index, loaded: false };
this.loadHovered();
} }
highwayFillCallback(context: ScriptableLineSegmentContext & { p0: { raw: any } }) { private async loadHovered() {
const point = context.p0.raw as ElevationProfilePoint; if (this._loadingHovered) {
return getHighwayColor(point.highway ?? '', point.sacScale, point.mtbScale); return; // the one being read is followed by the last one hovered
} }
this._loadingHovered = true;
try {
while (this._hovered && !this._hovered.loaded) {
const hovered = this._hovered;
const point = await this._data.point(hovered.index);
if (this._hovered !== hovered) {
continue; // another trackpoint is hovered, or none
}
hovered.loaded = true;
hovered.point = point;
// the move that was held can now show the tooltip
const move = this._heldMove;
this._heldMove = null;
if (move) {
this._canvas.dispatchEvent(new MouseEvent(move.type, move));
}
}
} finally {
this._loadingHovered = false;
}
}
/** The trackpoint that a segment of the chart starts at. */
private static segmentStart(context: ScriptableLineSegmentContext & { p0: { raw: any } }) {
return (context.p0.raw as ElevationProfilePoint).index;
}
slopeFillCallback = (context: ScriptableLineSegmentContext & { p0: { raw: any } }) => {
const segments = this._data.slopeSegments;
const index = ElevationProfile.segmentStart(context);
const segment = segments ? intervalAt(segments.starts, index) : -1;
return getSlopeColor(segment >= 0 ? segments!.slopes[segment] : NaN);
};
surfaceFillCallback = (context: ScriptableLineSegmentContext & { p0: { raw: any } }) => {
const index = ElevationProfile.segmentStart(context);
return getSurfaceColor(categoryValue(this._data.surface, index) ?? '');
};
highwayFillCallback = (context: ScriptableLineSegmentContext & { p0: { raw: any } }) => {
const index = ElevationProfile.segmentStart(context);
return getHighwayColor(
categoryValue(this._data.highway, index) ?? '',
categoryValue(this._data.sacScale, index),
categoryValue(this._data.mtbScale, index)
);
};
destroy() { destroy() {
this._request.release(); this._request.release();
@@ -100,7 +100,7 @@
<LayerControl /> <LayerControl />
<GPXLayers /> <GPXLayers />
{#if $files.size > 1} {#if $files.size > 1}
<div class="h-10 -translate-y-10 w-full pointer-events-none absolute z-30"> <div class="h-10 w-full pointer-events-none absolute bottom-0 left-0 z-30">
<FileList orientation="horizontal" /> <FileList orientation="horizontal" />
</div> </div>
{/if} {/if}
@@ -0,0 +1,191 @@
<script lang="ts">
import { setContext, untrack } from 'svelte';
import { CollapsibleTree, CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
import { CollapsibleTreeState } from '$lib/components/collapsible-tree/utils.svelte';
import { ScrollArea } from '$lib/components/ui/scroll-area/index';
import ExplorerTree from './ExplorerTree.svelte';
import ExplorerRow from './ExplorerRow.svelte';
import {
clickedInExplorer,
explorerFoldersKey,
explorerSelection,
renaming,
treeRows,
type ExplorerFolders,
} from './state.svelte';
import { openPaths } from '$lib/actions/file-actions';
import { i18n } from '$lib/i18n/i18n.svelte';
import { engine } from '$lib/engine';
import { selectedFileIds } from '$lib/engine/selection-helpers';
import { folderName, parentOf, subfolder } from '$lib/engine/workspace';
const { workspace, selection } = engine;
let folder = $derived($workspace?.folder);
// which folders are open: new ones when another folder is opened, the folder itself is open at
// first and its folders are closed
let trees = $derived(
folder
? {
root: new CollapsibleTreeState('open'),
folders: new CollapsibleTreeState('closed'),
}
: undefined
);
let container: HTMLElement | undefined = $state();
// the files that are selected, if they are in the folder
let selectedPaths = $derived(
selectedFileIds($selection)
.map((id) => $workspace?.files[id]?.path)
.filter((path): path is string => subfolder(folder, path) !== undefined)
);
let selectedPath = $derived(selectedPaths[0]);
$effect(() => {
// not what the click that was just made depends on, only the selection and the folder
const clicked = untrack(() => clickedInExplorer.path);
if (clicked !== undefined && selectedPath === clicked) {
return;
}
clickedInExplorer.path = undefined;
// what is selected elsewhere (the file list, the map) is selected here
explorerSelection.paths = untrack(() => selectedPaths);
explorerSelection.anchor = selectedPath;
if (folder && selectedPath !== undefined) {
return reveal(folder, selectedPath);
}
});
/**
* Opens the folders that hold a file and scrolls to the file, if it is not in view already. Its
* row may not be there yet, because the content of a folder is read when it is opened.
*/
function reveal(folder: string, path: string) {
openFolder(parentOf(path));
let frame = 0;
let attempts = 0;
const scroll = () => {
const row = container?.querySelector(`[data-path="${CSS.escape(path)}"]`);
if (row) {
row.scrollIntoView({ block: 'nearest' });
} else if (attempts++ < 120) {
frame = requestAnimationFrame(scroll);
}
};
frame = requestAnimationFrame(scroll);
return () => cancelAnimationFrame(frame);
}
/**
* The id of the node of a folder of the tree in `trees.folders`: the names of the folders it is
* in, and its own (see `CollapsibleTreeNode`). `undefined` for the open folder itself, which is
* in `trees.root`, and for what is not in it.
*/
function nodeId(path: string): string | undefined {
const names = subfolder(folder, `${path}/_`)?.split('/').filter(Boolean);
return names && names.length > 0 ? ['root', ...names].join('.') : undefined;
}
/** Opens a folder of the tree, and the folders it is in. */
function openFolder(path: string) {
if (!trees) return;
trees.root.get('root.folder').current = true;
const id = nodeId(path);
if (id === undefined) return;
const names = id.split('.');
names.forEach((_, i) => {
if (i > 0) trees!.folders.get(names.slice(0, i + 1).join('.')).current = true;
});
}
setContext<ExplorerFolders>(explorerFoldersKey, {
open: openFolder,
moved(from, to) {
const [fromId, toId] = [nodeId(from), nodeId(to)];
if (trees && fromId !== undefined && toId !== undefined) {
trees.folders.move(fromId, toId);
}
},
});
/** Enter opens the selected files for good. */
function onkeydown(event: KeyboardEvent) {
if (renaming.path !== undefined || event.key !== 'Enter') return;
const files = treeRows(container)
.filter((row) => row.kind === 'file' && explorerSelection.paths.includes(row.path))
.map((row) => row.path);
if (files.length > 0) {
openPaths(files);
event.preventDefault();
}
}
</script>
<!--
The tree of the open folder: its folders and GPX files, one level read at a time. The files are
opened from it (the file list has the ones that are open).
-->
{#if folder && trees}
<div class="flex flex-col h-full">
<div class="px-2 py-1 text-sm font-semibold shrink-0">{i18n._('menu.explorer')}</div>
<ScrollArea class="grow min-h-0">
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
bind:this={container}
data-explorer
role="tree"
tabindex="0"
class="flex flex-col pl-1 pr-3 pb-1 outline-none"
{onkeydown}
>
<!-- starts over when another folder is opened -->
{#key folder}
<!-- the folder itself -->
<CollapsibleTree side="left" treeState={trees.root} sticky>
<CollapsibleTreeNode id="folder">
{#snippet trigger()}
<ExplorerRow
path={folder}
name={folderName(folder)}
kind="root"
class="h-7"
/>
{/snippet}
{#snippet content(opened)}
<!-- what is in it is only read when it is open -->
{#if opened}
<div class="ml-4">
<CollapsibleTree side="left" treeState={trees.folders}>
<ExplorerTree />
</CollapsibleTree>
</div>
{/if}
{/snippet}
</CollapsibleTreeNode>
</CollapsibleTree>
{/key}
</div>
</ScrollArea>
</div>
{/if}
<style lang="postcss">
@reference "../../../app.css";
/* the color of the file list: a file and a folder (the whole row of its node, with its
chevron) under the pointer, or selected */
[data-explorer] :global([data-kind='file']:hover),
[data-explorer] :global([data-slot='button']:hover),
[data-explorer] :global([data-kind='file'][aria-selected='true']),
[data-explorer] :global([data-slot='button']:has([aria-selected='true'])) {
@apply bg-[var(--selection)];
}
/* the folder where what is dragged would go */
[data-explorer] :global([data-slot='button']:has([data-drop-target])) {
@apply ring-1 ring-primary bg-[var(--selection)];
}
</style>
@@ -0,0 +1,342 @@
<script lang="ts">
import * as ContextMenu from '$lib/components/ui/context-menu';
import {
ArrowUpRight,
FileX,
FolderOpen,
FolderX,
PenLine,
Plus,
Trash2,
} from '@lucide/svelte';
import { getContext, type Snippet } from 'svelte';
import { i18n } from '$lib/i18n/i18n.svelte';
import { engine } from '$lib/engine';
import { editableName, fileIdAt, isWithin, parentOf, relocated } from '$lib/engine/workspace';
import { closeFiles, closeFolder, openPaths, showEngineError } from '$lib/actions/file-actions';
import { isMac } from '$lib/utils';
import NameInput from './NameInput.svelte';
import {
clickedInExplorer,
creating,
dragging,
explorerFoldersKey,
explorerSelection,
renaming,
treeRows,
type ExplorerFolders,
} from './state.svelte';
let {
path,
name,
kind,
icon,
class: className = '',
style = '',
}: {
path: string;
/** What the row shows, the name of the file with its extension. */
name: string;
/** The open folder itself, a folder in it, or a GPX file. */
kind: 'root' | 'folder' | 'file';
icon?: Snippet;
class?: string;
style?: string;
} = $props();
const { workspace } = engine;
const folders = getContext<ExplorerFolders>(explorerFoldersKey);
let row: HTMLElement | undefined = $state();
// the open file of the row
let fileId = $derived(kind === 'file' ? fileIdAt($workspace, path) : undefined);
let selected = $derived(explorerSelection.paths.includes(path));
let editing = $derived(renaming.path === path);
let target = $derived(dragging.target === path);
/** What the menu and dragging act on: the selection, if the row is in it. */
function targets(): string[] {
return selected ? explorerSelection.paths : [path];
}
function select(paths: string[]) {
explorerSelection.paths = paths;
explorerSelection.anchor = path;
}
function onclick(event: MouseEvent) {
if (editing || kind === 'root') {
return;
}
if (event.metaKey || event.ctrlKey) {
// added to the selection, or taken out of it, without opening anything
select(
selected
? explorerSelection.paths.filter((other) => other !== path)
: [...explorerSelection.paths, path]
);
event.preventDefault();
event.stopPropagation();
} else if (event.shiftKey && explorerSelection.anchor !== undefined) {
// everything shown between the row where the selection began and this one
const paths = treeRows(row)
.filter((other) => other.kind !== 'root')
.map((other) => other.path);
const [from, to] = [paths.indexOf(explorerSelection.anchor), paths.indexOf(path)];
if (from >= 0 && to >= 0) {
explorerSelection.paths = paths.slice(Math.min(from, to), Math.max(from, to) + 1);
}
event.preventDefault();
event.stopPropagation();
} else if (event.detail <= 1) {
select([path]);
if (kind === 'file') {
// it is in view, the explorer does not have to scroll to it
clickedInExplorer.path = path;
if (fileId !== undefined) {
engine.select([fileId]);
} else {
openPaths([path], true);
}
}
}
}
function ondblclick() {
if (kind === 'file' && !editing) {
clickedInExplorer.path = path;
openPaths([path], false);
}
}
// the menu
/** The folder where what is made from the menu goes: the folder of the row. */
let folder = $derived(kind === 'file' ? undefined : path);
/**
* Asks for the name of a file or a folder to make in the folder of the row (see `creating`).
* With `route`, the file starts with an empty route.
*/
function create(what: 'file' | 'folder', route = false) {
if (folder === undefined) return;
// where what is being named is shown
folders.open(folder);
creating.kind = what;
creating.route = route;
creating.parent = folder;
}
function openSelected() {
const files = treeRows(row)
.filter((other) => other.kind === 'file' && targets().includes(other.path))
.map((other) => other.path);
if (files.length > 0) {
openPaths(files);
}
}
/** The open files among what the menu acts on. */
function openIds(): string[] {
return targets()
.map((other) => fileIdAt($workspace, other))
.filter((id): id is string => id !== undefined);
}
async function remove() {
const paths = targets();
explorerSelection.paths = [];
if (!(await engine.deletePaths(paths))) {
showEngineError();
}
}
// renaming, in the row
async function commit(value: string) {
if (renaming.path !== path) return;
renaming.path = undefined;
const name_ = value.trim();
if (name_ === '' || name_ === editableName(name, kind !== 'file')) return;
// the folder stays open or closed (before its new row is read, back if it is not renamed)
const to = relocated(path, parentOf(path), name_);
folders.moved(path, to);
if (!(await engine.renamePath(path, name_))) {
folders.moved(to, path);
showEngineError();
}
}
// dragging into folders
function ondragstart(event: DragEvent) {
if (kind === 'root' || editing) {
event.preventDefault();
return;
}
const paths = targets();
dragging.paths = paths;
event.dataTransfer?.setData('text/plain', paths.join('\n'));
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
}
}
function ondragend() {
dragging.paths = [];
dragging.target = undefined;
}
/** The folder where what is dropped on the row goes: the folder of a file, among its files. */
let dropFolder = $derived(kind === 'file' ? parentOf(path) : path);
function ondragover(event: DragEvent) {
if (dragging.paths.length === 0) return;
// not into itself, nor where it is already
if (dragging.paths.some((dragged) => isWithin(dropFolder, dragged))) return;
if (dragging.paths.every((dragged) => parentOf(dragged) === dropFolder)) return;
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
// the row of the folder shows it
dragging.target = dropFolder;
}
function ondragleave(event: DragEvent) {
// to another row, which tells where it would go (it was entered first)
const to = event.relatedTarget as Element | null;
if (to?.closest?.('[data-explorer-path]')) return;
dragging.target = undefined;
}
async function ondrop(event: DragEvent) {
event.preventDefault();
const paths = dragging.paths;
ondragend();
if (paths.length === 0) return;
// the folders stay open or closed (before their new rows are read, back if they are not moved)
const moves = paths.map((dragged) => [dragged, relocated(dragged, dropFolder)]);
moves.forEach(([from, to]) => folders.moved(from, to));
if (await engine.movePaths(paths, dropFolder)) {
// where they went is shown
folders.open(dropFolder);
} else {
moves.forEach(([from, to]) => folders.moved(to, from));
showEngineError();
}
}
</script>
<ContextMenu.Root
onOpenChange={(open) => {
if (open && fileId !== undefined) {
// the file is open for good once its menu is
engine.keepPreview([fileId]);
}
if (open && !selected && kind !== 'root') {
select([path]);
}
}}
>
<ContextMenu.Trigger class="grow min-w-0 pointer-events-auto outline-none">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
bind:this={row}
role="treeitem"
aria-selected={selected}
tabindex="-1"
data-explorer-path={path}
data-path={path}
data-kind={kind}
title={path}
draggable={kind !== 'root' && !editing}
data-drop-target={target || undefined}
class="flex flex-row items-center gap-1.5 min-w-0 rounded-md outline-none {className}"
{style}
{onclick}
{ondblclick}
{ondragstart}
{ondragend}
{ondragover}
ondragenter={ondragover}
{ondragleave}
{ondrop}
>
{@render icon?.()}
{#if editing}
<NameInput
value={editableName(name, kind !== 'file')}
oncommit={commit}
oncancel={() => (renaming.path = undefined)}
/>
{:else}
<span class="grow truncate text-left">{name}</span>
{/if}
</div>
</ContextMenu.Trigger>
<!-- the name being edited keeps the focus, which the menu would give back to the row -->
<ContextMenu.Content
onCloseAutoFocus={(event) => {
if (renaming.path !== undefined || creating.parent !== undefined) {
event.preventDefault();
}
}}
>
{#if kind !== 'file'}
<ContextMenu.Item onclick={() => create('file')}>
<Plus size="16" />
{i18n._('menu.explorer_menu.new_file')}
</ContextMenu.Item>
<ContextMenu.Item onclick={() => create('file', true)}>
<Plus size="16" />
{i18n._('menu.explorer_menu.new_route_file')}
</ContextMenu.Item>
<ContextMenu.Item onclick={() => create('folder')}>
<Plus size="16" />
{i18n._('menu.explorer_menu.new_folder')}
</ContextMenu.Item>
<ContextMenu.Separator />
{:else}
<ContextMenu.Item onclick={openSelected}>
<FolderOpen size="16" />
{i18n._('menu.explorer_menu.open')}
</ContextMenu.Item>
{/if}
<ContextMenu.Item onclick={() => engine.revealPath(path)}>
<ArrowUpRight size="16" />
{i18n._(isMac() ? 'menu.explorer_menu.reveal_finder' : 'menu.explorer_menu.reveal')}
</ContextMenu.Item>
<ContextMenu.Separator />
{#if kind !== 'root'}
<ContextMenu.Item
disabled={targets().length !== 1}
onclick={() => (renaming.path = path)}
>
<PenLine size="16" />
{i18n._('menu.explorer_menu.rename')}
</ContextMenu.Item>
<ContextMenu.Separator />
{/if}
{#if kind === 'root'}
<ContextMenu.Item onclick={closeFolder}>
<FolderX size="16" />
{i18n._('menu.close_folder')}
</ContextMenu.Item>
{:else}
{#if kind === 'file' && openIds().length > 0}
<ContextMenu.Item onclick={() => closeFiles(openIds())}>
<FileX size="16" />
{i18n._('menu.close')}
</ContextMenu.Item>
<ContextMenu.Separator />
{/if}
<ContextMenu.Item onclick={remove}>
<Trash2 size="16" />
{i18n._('menu.delete')}
</ContextMenu.Item>
{/if}
</ContextMenu.Content>
</ContextMenu.Root>
@@ -0,0 +1,155 @@
<script lang="ts">
import { CollapsibleTreeNode } from '$lib/components/collapsible-tree/index';
import { FileText, Folder, Route } from '@lucide/svelte';
import { getContext } from 'svelte';
import { get } from 'svelte/store';
import { toast } from 'svelte-sonner';
import { stickyKey, type StickyLevel } from '$lib/components/collapsible-tree/sticky';
import ExplorerTree from './ExplorerTree.svelte';
import ExplorerRow from './ExplorerRow.svelte';
import { engine, type FolderEntry } from '$lib/engine';
import { editableName, fileIdAt } from '$lib/engine/workspace';
import { i18n } from '$lib/i18n/i18n.svelte';
import { currentTool, Tool } from '$lib/components/toolbar/tools';
import { showEngineError } from '$lib/actions/file-actions';
import NameInput from './NameInput.svelte';
import { creating, explorerSelection } from './state.svelte';
let {
path,
}: {
/** The folder whose content is listed, the open folder if it is not given. */
path?: string;
} = $props();
const { workspace, folderVersion } = engine;
// the rows of the folders that stick above the files of this one
const level = getContext<StickyLevel>(stickyKey);
let entries: FolderEntry[] = $state([]);
// the folder of the list, where what is being made goes
let folder = $derived(path ?? $workspace?.folder);
let pending = $derived(
creating.parent !== undefined && creating.parent === folder ? creating.kind : undefined
);
// the folders come first, then the files (and what is being made goes at the top of its kind)
let folders = $derived(entries.filter((entry) => entry.isDir));
let files = $derived(entries.filter((entry) => !entry.isDir));
/**
* Makes the file or folder that was asked for, called `value`: nothing is made without a name,
* nor with the name of something that is there (the user is told, and can change it). A new
* file is selected, for the routing tool.
*/
async function create(value: string, retry: boolean) {
const kind = pending;
const parent = folder;
const route = creating.route;
if (kind === undefined || parent === undefined) return;
const name = value.trim();
const taken = entries.some(
(entry) =>
entry.isDir === (kind === 'folder') &&
editableName(entry.name, entry.isDir).toLowerCase() === name.toLowerCase()
);
if (name !== '' && taken) {
toast.error(i18n._('menu.metadata.name_taken'));
if (retry) return;
}
creating.parent = undefined;
if (name === '' || taken) return;
let created: string | undefined;
if (kind === 'file') {
const id = await engine.newFileIn(name, parent, route);
created = id === undefined ? undefined : get(workspace)?.files[id]?.path;
if (created !== undefined) {
currentTool.set(Tool.ROUTING);
}
} else {
created = await engine.createFolder(parent, name);
}
if (created === undefined) {
showEngineError();
return;
}
explorerSelection.paths = [created];
explorerSelection.anchor = created;
}
$effect(() => {
// read again when something changed on the file system
$folderVersion;
let current = true;
engine.listFolder(path).then((list) => {
if (current) {
entries = list;
}
});
return () => {
current = false;
};
});
</script>
<!-- the name of what is being made, which it is made with -->
{#snippet named(kind: 'file' | 'folder')}
<div class="flex flex-row items-center h-7 pl-1 pr-1 {kind === 'file' ? 'gap-1.5' : 'gap-1'}">
{#if kind === 'file' && creating.route}
<Route size="16" class="shrink-0" />
{:else if kind === 'file'}
<FileText size="16" class="shrink-0" />
{:else}
<Folder size="16" class="shrink-0" />
{/if}
<NameInput
placeholder={i18n._(
kind === 'folder'
? 'menu.explorer_menu.new_folder'
: creating.route
? 'menu.explorer_menu.new_route_file'
: 'menu.explorer_menu.new_file'
)}
oncommit={create}
oncancel={() => (creating.parent = undefined)}
/>
</div>
{/snippet}
{#if pending === 'folder'}
{@render named('folder')}
{/if}
{#each folders as entry (entry.path)}
<CollapsibleTreeNode id={entry.name}>
{#snippet trigger()}
<ExplorerRow path={entry.path} name={entry.name} kind="folder" class="h-7" />
{/snippet}
{#snippet content(opened)}
<!-- the content of a folder is only read when it is opened -->
{#if opened}
<div class="ml-4">
<ExplorerTree path={entry.path} />
</div>
{/if}
{/snippet}
</CollapsibleTreeNode>
{/each}
{#if pending === 'file'}
{@render named('file')}
{/if}
{#each files as entry (entry.path)}
<!-- the files that are not open are greyed -->
<ExplorerRow
path={entry.path}
name={entry.name}
kind="file"
class="w-full h-7 px-1 text-sm {fileIdAt($workspace, entry.path) === undefined
? 'text-muted-foreground'
: ''}"
style="scroll-margin-top: {level.top}px"
>
{#snippet icon()}
<FileText size="16" class="shrink-0" />
{/snippet}
</ExplorerRow>
{/each}
@@ -0,0 +1,45 @@
<script lang="ts">
let {
value = '',
placeholder,
oncommit,
oncancel,
}: {
value?: string;
placeholder?: string;
/** The name is given: with Enter (`enter`), or by leaving the field. */
oncommit: (value: string, enter: boolean) => void;
/** Escape: no name is given. */
oncancel: () => void;
} = $props();
function onkeydown(event: KeyboardEvent) {
// not for the row or the explorer it is in (a folder opens with Enter or Space, the selected
// files open with Enter)
event.stopPropagation();
if (event.key === 'Enter') {
oncommit((event.currentTarget as HTMLInputElement).value, true);
} else if (event.key === 'Escape') {
oncancel();
}
}
</script>
<!-- the name of a file or a folder of the explorer, being given or changed -->
<input
class="h-6 grow min-w-0 px-1 text-sm font-normal not-italic bg-background border rounded outline-none"
{value}
{placeholder}
{@attach (input) => {
// once it is shown: it can be in a folder that is opening
const frame = requestAnimationFrame(() => {
input.focus();
input.select();
});
return () => cancelAnimationFrame(frame);
}}
{onkeydown}
onkeyup={(event) => event.stopPropagation()}
onclick={(event) => event.stopPropagation()}
onblur={(event) => oncommit(event.currentTarget.value, false)}
/>
Loaded 100 of 229 files, more files were not shown because too many files have changed in this diff. Show more