diff --git a/gpx-rs/engine/src/engine/engine.rs b/gpx-rs/engine/src/engine/engine.rs index 9a6e8c319..929bafb92 100644 --- a/gpx-rs/engine/src/engine/engine.rs +++ b/gpx-rs/engine/src/engine/engine.rs @@ -17,6 +17,7 @@ pub struct Engine { structure_cache: FileStructureCache, diff: Option, order_changed: bool, + selection_changed: bool, statistics_buffer: StatisticsBuffer, } @@ -45,6 +46,16 @@ impl Engine { self.order_changed } + /// Currently selected elements. + pub fn selection(&self) -> &Selection { + &self.selection + } + + /// Whether the last action changed the selection. + pub fn selection_changed(&self) -> bool { + self.selection_changed + } + /// Coordinates (`[lng, lat, ...]`) of the trackpoints of a segment. pub fn segment_coordinates(&self, id: &TrackSegmentId) -> &[f64] { self.coordinates_cache.segment(id) @@ -93,12 +104,15 @@ impl Engine { None } }; - let selection_changed = self.selection != selection_before; - self.order_changed = self.order.0 != order_before; - let changed = self.diff.is_some() || selection_changed || self.order_changed; + let changed = self.diff.is_some() + || self.selection != selection_before + || self.order.0 != order_before; if changed { self.refresh(); } + // after the refresh, which also syncs the order and selection with the files (undo, redo) + self.order_changed = self.order.0 != order_before; + self.selection_changed = self.selection != selection_before; changed } @@ -374,6 +388,7 @@ mod tests { assert!(diff.is_some()); let diff = diff.as_ref().unwrap(); assert_eq!(diff.removed, vec![empty_id]); + assert!(engine.order_changed()); assert!(engine.file_structure(&empty_id).is_none()); assert!(engine.execute(Action::Undo)); assert!(engine.order().is_empty()); @@ -381,6 +396,24 @@ mod tests { assert!(engine.segment_coordinates(&seg_id).is_empty()); } + #[test] + fn test_selection_changed_flag() { + let mut engine = Engine::default(); + new(&mut engine, "a"); + let a = engine.order()[0]; + // a new file is selected by the edit + assert!(engine.selection_changed()); + assert!(!engine.execute(Action::Select { file_ids: vec![a] })); + assert!(!engine.selection_changed()); + assert!(engine.execute(Action::Select { file_ids: vec![] })); + assert!(engine.selection_changed()); + // undoing drops the selected file from the selection + engine.execute(Action::Select { file_ids: vec![a] }); + assert!(engine.execute(Action::Undo)); + assert!(engine.selection_changed()); + assert_eq!(engine.selection(), &Selection::Empty); + } + #[test] fn test_undo_updates_statistics() { let mut engine = Engine::default(); diff --git a/gpx-rs/wasm/src/lib.rs b/gpx-rs/wasm/src/lib.rs index b66165ccd..4dbbb689a 100644 --- a/gpx-rs/wasm/src/lib.rs +++ b/gpx-rs/wasm/src/lib.rs @@ -332,8 +332,16 @@ pub fn select_all() -> bool { #[wasm_bindgen(typescript_custom_section)] const FILE_STRUCTURE_TS: &str = r#" +export type Selection = + | { type: 'empty' } + | { type: 'file'; fileIds: string[] } + | { type: 'track'; fileId: string; trackIds: string[] } + | { type: 'segment'; fileId: string; trackId: string; segmentIds: string[] } + | { type: 'waypoints'; fileId: string } + | { type: 'waypoint'; fileId: string; waypointIds: string[] }; export interface FilesUpdate { orderChanged: boolean; + selectionChanged: boolean; /** Files to read the structure of. */ added: string[]; /** Files whose structure changed: reread it. */ @@ -369,6 +377,8 @@ export interface WaypointNode { #[wasm_bindgen] extern "C" { + #[wasm_bindgen(typescript_type = "Selection")] + pub type Selection; #[wasm_bindgen(typescript_type = "FilesUpdate")] pub type FilesUpdate; #[wasm_bindgen(typescript_type = "FileStructure | undefined")] @@ -452,6 +462,52 @@ pub fn file_structure(file_id: &str) -> FileStructure { structure.unchecked_into() } +fn uuids(items: impl IntoIterator, uuid: impl Fn(T) -> uuid::Uuid) -> Array { + items + .into_iter() + .map(|item| JsValue::from(uuid(item).to_string())) + .collect() +} + +/// The current selection (ids are UUID strings). +#[wasm_bindgen] +pub fn selection() -> Selection { + use engine::Selection as S; + let object = Object::new(); + with_engine(|e| match e.selection() { + S::Empty => set(&object, "type", "empty"), + S::File { file_ids } => { + set(&object, "type", "file"); + set(&object, "fileIds", uuids(file_ids, |id| id.0)); + } + S::Track { file_id, trk_ids } => { + set(&object, "type", "track"); + set(&object, "fileId", file_id.0.to_string()); + set(&object, "trackIds", uuids(trk_ids, |id| id.0)); + } + S::TrackSegment { + file_id, + trk_id, + trkseg_ids, + } => { + set(&object, "type", "segment"); + set(&object, "fileId", file_id.0.to_string()); + set(&object, "trackId", trk_id.0.to_string()); + set(&object, "segmentIds", uuids(trkseg_ids, |id| id.0)); + } + S::Waypoints { file_id } => { + set(&object, "type", "waypoints"); + set(&object, "fileId", file_id.0.to_string()); + } + S::Waypoint { file_id, wpt_ids } => { + set(&object, "type", "waypoint"); + set(&object, "fileId", file_id.0.to_string()); + set(&object, "waypointIds", uuids(wpt_ids, |id| id.0)); + } + }); + object.unchecked_into() +} + /// What the last action changed. Read it right after each action. #[wasm_bindgen] pub fn last_update() -> FilesUpdate { @@ -459,6 +515,7 @@ pub fn last_update() -> FilesUpdate { with_engine(|e| { let diff = e.last_diff().clone().unwrap_or_default(); set(&update, "orderChanged", e.order_changed()); + set(&update, "selectionChanged", e.selection_changed()); set(&update, "added", ids(&diff.added)); set(&update, "modified", ids(&diff.modified)); set(&update, "removed", ids(&diff.removed)); diff --git a/website/src/lib/engine.ts b/website/src/lib/engine.ts index 95e5517ce..3e41c7a09 100644 --- a/website/src/lib/engine.ts +++ b/website/src/lib/engine.ts @@ -1,21 +1,152 @@ -import { writable, type Subscriber, type Writable } from 'svelte/store'; +import { browser } from '$app/environment'; +import { writable, type Readable } from 'svelte/store'; +import type { FileStructure, Selection } from 'gpx-rs'; +export type { + FileStructure, + FilesUpdate, + Selection, + TrackNode, + SegmentNode, + WaypointNode, +} from 'gpx-rs'; + +type Wasm = typeof import('gpx-rs'); + +/** Hyphenated UUID strings (as found in the file structures) to the concatenated 16-byte form. */ +function idsToBytes(ids: string[]): Uint8Array { + const bytes = new Uint8Array(ids.length * 16); + ids.forEach((id, i) => { + const hex = id.replaceAll('-', ''); + for (let j = 0; j < 16; j++) { + bytes[i * 16 + j] = parseInt(hex.slice(2 * j, 2 * j + 2), 16); + } + }); + return bytes; +} + +/** + * Thin wrapper around the WASM engine. + * + * The engine owns the files; this class mirrors what the UI needs in stores. After each action, + * only what the engine reports as changed is read again: the order of the files, and the + * structure of the files that were added or modified. + */ class Engine { - _engine: Writable; + private wasm: Wasm | null = null; + /** Resolves once the engine can be used (never on the server). */ + readonly ready: Promise; + + private _order = writable([]); + private _structures = writable>(new Map()); + private _selection = writable({ type: 'empty' }); + + /** Ids of the files in display order. */ + readonly order: Readable = { subscribe: this._order.subscribe }; + /** What is currently selected. */ + readonly selection: Readable = { subscribe: this._selection.subscribe }; + /** Structure (names, tracks, segments, waypoints and their ids) of each file. */ + readonly structures: Readable> = { + subscribe: this._structures.subscribe, + }; constructor() { - this._engine = writable(null); - import('gpx-rs').then((wasm) => { - this._engine.set(new wasm.Engine(this.onUpdate)); - }); + this.ready = browser + ? import('gpx-rs').then((wasm) => { + wasm.start(); + this.wasm = wasm; + }) + : new Promise(() => {}); } - subscribe(run: Subscriber, invalidate?: () => void) { - return this._engine.subscribe(run, invalidate); + // Actions. Each one resolves to whether the engine changed something. + + newFile(name: string) { + return this.run((w) => w.new_file(name)); } - onUpdate(message: String) { - console.log('message from WASM: ' + message); + loadFile(data: Uint8Array) { + return this.run((w) => w.load_file(data)); + } + + duplicate() { + return this.run((w) => w.duplicate()); + } + + delete() { + return this.run((w) => w._delete()); + } + + deleteAll() { + return this.run((w) => w.delete_all()); + } + + undo() { + return this.run((w) => w.undo()); + } + + redo() { + return this.run((w) => w.redo()); + } + + select(fileIds: string[]) { + return this.run((w) => w.select(idsToBytes(fileIds))); + } + + addSelect(fileIds: string[]) { + return this.run((w) => w.add_select(idsToBytes(fileIds))); + } + + selectAll() { + return this.run((w) => w.select_all()); + } + + /** Moves the files, in the given order, to `index` among the other files. */ + reorder(fileIds: string[], index: number) { + return this.run((w) => w.reorder(idsToBytes(fileIds), index)); + } + + // Coordinates, as flat [lng, lat, ...] arrays. They are copied out of the WASM memory. + // Refetch them when the `rev` of the segment (or the `waypointsRev` of the file) changed. + + segmentCoordinates(segmentId: string): Float64Array { + return this.wasm?.segment_coordinates(segmentId).slice() ?? new Float64Array(); + } + + waypointCoordinates(fileId: string): Float64Array { + return this.wasm?.waypoint_coordinates(fileId).slice() ?? new Float64Array(); + } + + private async run(action: (wasm: Wasm) => boolean): Promise { + await this.ready; + const wasm = this.wasm!; + const changed = action(wasm); + this.sync(wasm); + return changed; + } + + /** Reads what the last action changed. */ + private sync(wasm: Wasm) { + const update = wasm.last_update(); + if (update.selectionChanged) { + this._selection.set(wasm.selection()); + } + if (update.orderChanged) { + this._order.set(wasm.file_order()); + } + if (update.added.length + update.modified.length + update.removed.length > 0) { + this._structures.update((structures) => { + const next = new Map(structures); + update.removed.forEach((id) => next.delete(id)); + [...update.added, ...update.modified].forEach((id) => { + const structure = wasm.file_structure(id); + if (structure) { + next.set(id, structure); + } + }); + return next; + }); + } } }