gantz_egui 0.5.0

UI traits and widgets that make up the GUI for gantz, an environment for creative systems.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
//! Export/import representation for sharing node sets between gantz instances.
//!
//! The [`Export`] type bundles a [`gantz_ca::Registry`] subset with optional
//! [`crate::SceneView`] layout data. Serialization uses the `.gantz` S-expression text
//! format (see [`crate::format`]) under the `.gantz` file extension.

use gantz_ca::{CaHash, CommitAddr, registry::MergeResult};
use gantz_core::node::{self, GetNode, graph::Graph};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use std::collections::{HashMap, HashSet};

/// File extension for gantz export files (without the leading dot).
pub const FILE_EXTENSION: &str = "gantz";

/// An error produced when parsing the raw bytes of a `.gantz` file.
#[derive(Debug)]
pub enum ParseExportError {
    Utf8(std::str::Utf8Error),
    /// The S-expression text format failed to parse.
    Format(crate::format::FormatError),
}

impl std::fmt::Display for ParseExportError {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        match self {
            Self::Utf8(e) => write!(f, "invalid UTF-8: {e}"),
            Self::Format(e) => write!(f, "failed to parse .gantz text: {e}"),
        }
    }
}

impl std::error::Error for ParseExportError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::Utf8(e) => Some(e),
            Self::Format(e) => Some(e),
        }
    }
}

/// A serializable bundle of a registry subset and its associated view state.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct Export<G> {
    pub registry: gantz_ca::Registry<G>,
    #[serde(default, serialize_with = "gantz_ca::serde_sorted::serialize_map")]
    pub views: HashMap<CommitAddr, crate::SceneView>,
    /// Maps a graph *name* to its associated demo graph name (a `demo-*` name).
    ///
    /// Keyed by name (rather than commit) so the association survives an edit:
    /// editing a graph mints a new commit but keeps the name.
    #[serde(default)]
    pub demos: HashMap<String, String>,
}

/// Produce an [`Export`] by filtering views to commits, and demos to names,
/// present in the registry.
pub fn export_with<G>(
    registry: gantz_ca::Registry<G>,
    all_views: &HashMap<CommitAddr, crate::SceneView>,
    all_demos: &HashMap<String, String>,
) -> Export<G>
where
    G: Clone,
{
    let commits = registry.commits();
    let views = all_views
        .iter()
        .filter(|(ca, _)| commits.contains_key(ca))
        .map(|(&ca, v)| (ca, v.clone()))
        .collect();
    let names = registry.names();
    let demos = all_demos
        .iter()
        .filter(|(name, _)| names.contains_key(name.as_str()))
        .map(|(name, demo)| (name.clone(), demo.clone()))
        .collect();
    Export {
        registry,
        views,
        demos,
    }
}

/// Parse the raw bytes of a `.gantz` file into an [`Export`].
///
/// The file is the `.gantz` S-expression text format (see [`crate::format`]).
/// Graphs the document does not commit explicitly (hand-authored graphs with no
/// `(commits ...)` entry) are stamped with the current time. Use
/// [`parse_export_at`] to stamp them with a fixed timestamp instead.
pub fn parse_export<N>(bytes: &[u8]) -> Result<Export<Graph<N>>, ParseExportError>
where
    N: Serialize + DeserializeOwned + CaHash + gantz_format::NodeSugar + 'static,
{
    parse_export_at(bytes, now())
}

/// Like [`parse_export`], but stamps uncommitted (hand-authored) graphs with the
/// given timestamp rather than the current time.
///
/// A fixed timestamp makes the resulting commit addresses reproducible across
/// loads. This matters for content that is re-parsed and whose graphs reference
/// each other by content address - e.g. the baked-in base, which is parsed both
/// at startup and on demo reset: a wall-clock timestamp would give each parse
/// distinct commit addresses, so a reset demo's `ref`s would point at commits
/// absent from the already-loaded registry.
pub fn parse_export_at<N>(
    bytes: &[u8],
    now: gantz_ca::Timestamp,
) -> Result<Export<Graph<N>>, ParseExportError>
where
    N: Serialize + DeserializeOwned + CaHash + gantz_format::NodeSugar + 'static,
{
    let text = std::str::from_utf8(bytes).map_err(ParseExportError::Utf8)?;
    crate::format::from_str(text, now).map_err(ParseExportError::Format)
}

/// Like [`parse_export_at`], resolving names the document does not define
/// through `seed` (externally-known name -> head commit associations). Lets a
/// base source reference graphs another source defines - see
/// [`gantz_format::from_str_seeded`].
pub fn parse_export_seeded_at<N>(
    bytes: &[u8],
    now: gantz_ca::Timestamp,
    seed: &std::collections::BTreeMap<String, gantz_ca::CommitAddr>,
) -> Result<Export<Graph<N>>, ParseExportError>
where
    N: Serialize + DeserializeOwned + CaHash + gantz_format::NodeSugar + 'static,
{
    let text = std::str::from_utf8(bytes).map_err(ParseExportError::Utf8)?;
    crate::format::from_str_seeded(text, now, seed).map_err(ParseExportError::Format)
}

/// The current time as a [`gantz_ca::Timestamp`] (duration since the Unix epoch).
fn now() -> gantz_ca::Timestamp {
    web_time::SystemTime::now()
        .duration_since(web_time::UNIX_EPOCH)
        .unwrap_or_default()
}

/// The unique root name of an export, if it has exactly one.
///
/// `get_node` resolves node lookups outside the export (e.g. builtins).
pub fn unique_root_name<N>(get_node: GetNode, export: &Export<Graph<N>>) -> Option<String>
where
    N: gantz_core::Node,
{
    let mut roots = gantz_core::reg::root_names(get_node, &export.registry);
    (roots.len() == 1).then(|| roots.pop().unwrap())
}

/// Build and serialize an [`Export`] for the given heads as `.gantz` text.
///
/// Covers both export-head and export-all-named: the export contains the heads'
/// transitively required commits along with their views and demos. File IO
/// stays with the caller.
pub fn export_heads_sexpr<N>(
    get_node: GetNode,
    registry: &gantz_ca::Registry<Graph<N>>,
    all_views: &HashMap<CommitAddr, crate::SceneView>,
    all_demos: &HashMap<String, String>,
    heads: impl IntoIterator<Item = impl std::borrow::Borrow<gantz_ca::Head>>,
) -> Result<String, crate::format::FormatError>
where
    N: Serialize + DeserializeOwned + gantz_core::Node + Clone + gantz_format::NodeSugar,
{
    let export_registry = gantz_core::reg::export_heads(get_node, registry, heads);
    let export = export_with(export_registry, all_views, all_demos);
    crate::format::to_string(&export)
}

/// As [`export_heads_sexpr`], but serializes in the inline-name format (see
/// [`crate::format::to_string_named`]): graphs named inline, no commits/names
/// tables, references by name. Used for the baked-in base so its file stays
/// hand-editable and free of churning addresses.
pub fn export_heads_sexpr_named<N>(
    get_node: GetNode,
    registry: &gantz_ca::Registry<Graph<N>>,
    all_views: &HashMap<CommitAddr, crate::SceneView>,
    all_demos: &HashMap<String, String>,
    heads: impl IntoIterator<Item = impl std::borrow::Borrow<gantz_ca::Head>>,
) -> Result<String, crate::format::FormatError>
where
    N: Serialize + DeserializeOwned + gantz_core::Node + Clone + gantz_format::NodeSugar,
{
    let export_registry = gantz_core::reg::export_heads(get_node, registry, heads);
    let export = export_with(export_registry, all_views, all_demos);
    crate::format::to_string_named(&export)
}

/// As [`export_heads_sexpr_named`], but exports EXACTLY the given names with
/// no transitive dependency closure: references to graphs outside the set are
/// written by name only, without their `(graph ...)` blocks.
///
/// Used for per-source base write-back, where a source's file must contain
/// only its own graphs - refs into other sources stay by name, and loading
/// resolves them through the seeded parse (see [`parse_export_seeded_at`]).
pub fn export_names_sexpr_named<N>(
    registry: &gantz_ca::Registry<Graph<N>>,
    all_views: &HashMap<CommitAddr, crate::SceneView>,
    all_demos: &HashMap<String, String>,
    names: impl IntoIterator<Item = impl AsRef<str>>,
) -> Result<String, crate::format::FormatError>
where
    N: Serialize + DeserializeOwned + gantz_core::Node + Clone + gantz_format::NodeSugar,
{
    let requested: std::collections::HashSet<String> = names
        .into_iter()
        .map(|name| name.as_ref().to_string())
        .collect();
    let required: std::collections::HashSet<gantz_ca::CommitAddr> = requested
        .iter()
        .filter_map(|name| registry.names().get(name).copied())
        .collect();
    let mut export_registry = registry.export(&required);
    // `export` keeps every name whose commit survives - identical graphs
    // across sources share commits, so a foreign name could ride along.
    // Restrict to exactly the requested names (their descriptions follow).
    let extra: Vec<String> = export_registry
        .names()
        .keys()
        .filter(|name| !requested.contains(*name))
        .cloned()
        .collect();
    for name in extra {
        export_registry.remove_name(&name);
        export_registry.set_description(name, String::new());
    }
    let export = export_with(export_registry, all_views, all_demos);
    crate::format::to_string_named(&export)
}

/// Merge an [`Export`] into an existing registry, views and demos maps.
///
/// Incoming views and demos for new commits are inserted; existing entries for
/// known commits are kept.
pub fn merge_with<G>(
    registry: &mut gantz_ca::Registry<G>,
    views: &mut HashMap<CommitAddr, crate::SceneView>,
    demos: &mut HashMap<String, String>,
    export: Export<G>,
) -> MergeResult {
    let result = registry.merge(export.registry);
    for (ca, v) in export.views {
        views.entry(ca).or_insert(v);
    }
    for (name, d) in export.demos {
        demos.entry(name).or_insert(d);
    }
    result
}

/// Derive a default export filename from a [`gantz_ca::Head`].
pub fn default_filename(head: &gantz_ca::Head) -> String {
    match head {
        gantz_ca::Head::Branch(name) => format!("{name}.{FILE_EXTENSION}"),
        gantz_ca::Head::Commit(ca) => format!("{}.{FILE_EXTENSION}", ca.display_short()),
    }
}

/// Check if a path has the `.gantz` extension.
pub fn is_gantz_path(path: &std::path::Path) -> bool {
    path.extension()
        .and_then(|ext| ext.to_str())
        .map(|ext| ext.eq_ignore_ascii_case(FILE_EXTENSION))
        .unwrap_or(false)
}

/// Check if an optional path is a `.gantz` file.
///
/// Returns `true` when the path is absent (e.g. on web) so that files without
/// a known path are accepted speculatively.
pub fn is_maybe_gantz(path: Option<&std::path::Path>) -> bool {
    path.map(is_gantz_path).unwrap_or(true)
}

/// Read bytes from an [`egui::DroppedFile`].
///
/// Tries `file.bytes` first (web), then `std::fs::read` from `file.path` (desktop).
pub fn read_dropped_file(file: &egui::DroppedFile) -> Option<Vec<u8>> {
    if let Some(ref bytes) = file.bytes {
        return Some(bytes.to_vec());
    }
    if let Some(ref path) = file.path {
        return std::fs::read(path).ok();
    }
    None
}

/// Reserved registry name under which a copied subgraph travels inside a
/// clipboard `.gantz` document (see [`copied_to_string`]).
const CLIPBOARD_NAME: &str = "clipboard";

/// An error produced when parsing a clipboard payload.
#[derive(Debug)]
pub enum ParseCopiedError {
    /// The text was not a valid `.gantz` document.
    Format(crate::format::FormatError),
    /// The document parsed but carried no clipboard graph.
    NotClipboard,
}

impl std::fmt::Display for ParseCopiedError {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        match self {
            Self::Format(e) => write!(f, "failed to parse .gantz text: {e}"),
            Self::NotClipboard => write!(f, "document carries no `{CLIPBOARD_NAME}` graph"),
        }
    }
}

impl std::error::Error for ParseCopiedError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::Format(e) => Some(e),
            Self::NotClipboard => None,
        }
    }
}

/// A clipboard payload for copied graph nodes.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct Copied<N> {
    /// Registry dependencies referenced by copied nodes (e.g. Ref nodes).
    pub export: Export<Graph<N>>,
    /// The subgraph of selected nodes and their internal edges.
    pub graph: Graph<N>,
    /// Positions of nodes in the subgraph.
    pub positions: egui_graph::Layout,
}

/// Build a [`Copied`] payload from the selected nodes in a graph.
pub fn copy<N>(
    registry: &gantz_ca::Registry<Graph<N>>,
    all_views: &HashMap<CommitAddr, crate::SceneView>,
    graph: &Graph<N>,
    selected: &HashSet<node::graph::NodeIx>,
    layout: &egui_graph::Layout,
) -> Copied<N>
where
    N: Clone + gantz_core::Node,
{
    let subgraph = gantz_core::graph::extract_subgraph(graph, selected);

    // Build positions: iterate selected nodes in sorted order (matching
    // extract_subgraph's deterministic order) alongside new node indices.
    let mut positions = egui_graph::Layout::default();
    let sorted: std::collections::BTreeSet<_> = selected.iter().copied().collect();
    for (old_ix, new_ix) in sorted.iter().zip(subgraph.node_indices()) {
        let old_id = egui_graph::NodeId(old_ix.index() as u64);
        let new_id = egui_graph::NodeId(new_ix.index() as u64);
        if let Some(&pos) = layout.get(&old_id) {
            positions.insert(new_id, pos);
        }
    }

    // Collect registry deps transitively: the commits the selected nodes
    // reference, and the commits *those* graphs reference in turn (a nested
    // graph that itself contains nested graphs), so the whole subtree travels
    // with the clipboard.
    let mut required_commits = HashSet::new();
    let mut stack: Vec<CommitAddr> = subgraph
        .node_weights()
        .flat_map(|n| n.required_addrs())
        .map(CommitAddr::from)
        .filter(|ca| registry.commits().contains_key(ca))
        .collect();
    while let Some(commit_ca) = stack.pop() {
        if !required_commits.insert(commit_ca) {
            continue;
        }
        if let Some(nested) = registry.commit_graph_ref(&commit_ca) {
            for ca in nested.node_weights().flat_map(|n| n.required_addrs()) {
                let dep = CommitAddr::from(ca);
                if registry.commits().contains_key(&dep) {
                    stack.push(dep);
                }
            }
        }
    }
    let export_registry = registry.export(&required_commits);
    let export = export_with(export_registry, all_views, &HashMap::new());

    Copied {
        export,
        graph: subgraph,
        positions,
    }
}

/// Paste a [`Copied`] payload into a target graph.
///
/// Merges registry dependencies, adds the subgraph nodes/edges, and maps
/// positions with the given offset. Returns the new node indices in the
/// target graph.
pub fn paste<N>(
    registry: &mut gantz_ca::Registry<Graph<N>>,
    views: &mut HashMap<CommitAddr, crate::SceneView>,
    demos: &mut HashMap<String, String>,
    target_graph: &mut Graph<N>,
    target_layout: &mut egui_graph::Layout,
    copied: &Copied<N>,
    offset: egui::Vec2,
) -> Vec<node::graph::NodeIx>
where
    N: Clone,
{
    merge_with(registry, views, demos, copied.export.clone());
    let new_indices = gantz_core::graph::add_subgraph(target_graph, &copied.graph);

    // Map positions from subgraph indices to target indices with offset.
    for (sub_ix, &target_ix) in copied.graph.node_indices().zip(new_indices.iter()) {
        let sub_id = egui_graph::NodeId(sub_ix.index() as u64);
        let target_id = egui_graph::NodeId(target_ix.index() as u64);
        if let Some(&pos) = copied.positions.get(&sub_id) {
            target_layout.insert(target_id, pos + offset);
        }
    }

    new_indices
}

/// Serialize a [`Copied`] payload as a `.gantz` document.
///
/// The copied subgraph rides as a graph named `clipboard` - its positions stored
/// as that graph's layout view - alongside the registry dependencies, so the
/// whole payload is one ordinary `.gantz` document. [`copied_from_str`] reverses
/// this.
pub fn copied_to_string<N>(copied: &Copied<N>) -> Result<String, crate::format::FormatError>
where
    N: Serialize + DeserializeOwned + CaHash + Clone + gantz_format::NodeSugar + 'static,
{
    // Add the subgraph to the dependency registry as a fresh root commit named
    // `CLIPBOARD_NAME`. A fixed timestamp keeps the payload deterministic.
    let mut registry = copied.export.registry.clone();
    let g_addr = registry.add_graph(copied.graph.clone());
    let commit_ca = registry.add_commit(gantz_ca::Commit::new(
        std::time::Duration::ZERO,
        None,
        g_addr,
    ));
    registry.insert_name(CLIPBOARD_NAME.to_string(), commit_ca);

    // Carry the positions as the clipboard graph's layout view. The camera is
    // irrelevant for a clipboard payload, so use the default.
    let mut views = copied.export.views.clone();
    views.insert(
        commit_ca,
        crate::SceneView {
            camera: crate::Camera::default(),
            layout: copied.positions.clone(),
        },
    );

    let export = Export {
        registry,
        views,
        demos: copied.export.demos.clone(),
    };
    crate::format::to_string(&export)
}

/// Parse a clipboard payload produced by [`copied_to_string`].
///
/// Splits the `clipboard` graph (and its positions) back out from the registry
/// dependencies.
pub fn copied_from_str<N>(text: &str) -> Result<Copied<N>, ParseCopiedError>
where
    N: Serialize + DeserializeOwned + CaHash + Clone + gantz_format::NodeSugar + 'static,
{
    let mut export = crate::format::from_str::<N>(text, now()).map_err(ParseCopiedError::Format)?;

    let clip_ca = export
        .registry
        .names()
        .get(CLIPBOARD_NAME)
        .copied()
        .ok_or(ParseCopiedError::NotClipboard)?;
    let graph = export
        .registry
        .commit_graph_ref(&clip_ca)
        .cloned()
        .ok_or(ParseCopiedError::NotClipboard)?;
    let positions = export
        .views
        .get(&clip_ca)
        .map(|view| view.layout.clone())
        .unwrap_or_default();

    // Everything but the clipboard commit is a dependency. `export` filters
    // names to the kept commits, so the `clipboard` name drops out with it.
    let deps: HashSet<CommitAddr> = export
        .registry
        .commits()
        .keys()
        .copied()
        .filter(|&ca| ca != clip_ca)
        .collect();
    let registry = export.registry.export(&deps);
    export.views.remove(&clip_ca);

    Ok(Copied {
        export: Export {
            registry,
            views: export.views,
            demos: export.demos,
        },
        graph,
        positions,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use gantz_ca::{Commit, ContentAddr};
    use std::{collections::BTreeMap, time::Duration};

    fn graph_addr(n: u8) -> gantz_ca::GraphAddr {
        gantz_ca::GraphAddr::from(ContentAddr::from([n; 32]))
    }

    fn commit_addr_raw(n: u8) -> CommitAddr {
        CommitAddr::from(ContentAddr::from([n; 32]))
    }

    fn test_export() -> Export<String> {
        let ga = graph_addr(1);
        let ca = commit_addr_raw(10);
        let commit = Commit::new(Duration::from_secs(1), None, ga);
        let registry = gantz_ca::Registry::new(
            HashMap::from([(ga, "graph_a".to_string())]),
            HashMap::from([(ca, commit)]),
            BTreeMap::from([("alpha".to_string(), ca)]),
        );
        Export {
            registry,
            views: HashMap::new(),
            demos: HashMap::new(),
        }
    }

    #[test]
    fn export_merge_recovers_data() {
        let export = test_export();
        let mut target = gantz_ca::Registry::<String>::default();
        let mut views = HashMap::new();
        let mut demos = HashMap::new();
        let result = merge_with(&mut target, &mut views, &mut demos, export);
        assert_eq!(result.names_added, vec!["alpha".to_string()]);
        assert!(result.names_replaced.is_empty());
        let ca = commit_addr_raw(10);
        assert!(target.commits().contains_key(&ca));
        assert_eq!(target.names().get("alpha"), Some(&ca));
    }

    #[test]
    fn export_with_filters_views() {
        let ga = graph_addr(1);
        let ca = commit_addr_raw(10);
        let cb = commit_addr_raw(20);
        let commit = Commit::new(Duration::from_secs(1), None, ga);
        let registry = gantz_ca::Registry::new(
            HashMap::from([(ga, "g".to_string())]),
            HashMap::from([(ca, commit)]),
            BTreeMap::new(),
        );
        let mut all_views = HashMap::new();
        all_views.insert(ca, crate::SceneView::default());
        all_views.insert(cb, crate::SceneView::default()); // cb not in registry
        let export = export_with(registry, &all_views, &HashMap::new());
        assert!(export.views.contains_key(&ca));
        assert!(!export.views.contains_key(&cb));
    }

    #[test]
    fn export_with_filters_demos() {
        let ga = graph_addr(1);
        let ca = commit_addr_raw(10);
        let commit = Commit::new(Duration::from_secs(1), None, ga);
        let registry = gantz_ca::Registry::new(
            HashMap::from([(ga, "g".to_string())]),
            HashMap::from([(ca, commit)]),
            BTreeMap::from([("alpha".to_string(), ca)]),
        );
        let all_demos = HashMap::from([
            ("alpha".to_string(), "demo-alpha".to_string()),
            // `beta` is not a name in the registry, so it is dropped.
            ("beta".to_string(), "demo-beta".to_string()),
        ]);
        let export = export_with(registry, &HashMap::new(), &all_demos);
        assert_eq!(
            export.demos.get("alpha").map(String::as_str),
            Some("demo-alpha")
        );
        assert!(!export.demos.contains_key("beta"));
    }

    #[test]
    fn merge_with_keeps_existing_views() {
        let ga = graph_addr(1);
        let ca = commit_addr_raw(10);
        let commit = Commit::new(Duration::from_secs(1), None, ga);
        let mut registry = gantz_ca::Registry::new(
            HashMap::from([(ga, "g".to_string())]),
            HashMap::from([(ca, commit.clone())]),
            BTreeMap::new(),
        );
        let mut existing_view = crate::SceneView::default();
        existing_view
            .layout
            .insert(egui_graph::NodeId(0), Default::default());
        let mut views = HashMap::from([(ca, existing_view)]);
        let mut demos = HashMap::new();
        let export = Export {
            registry: gantz_ca::Registry::new(
                HashMap::from([(ga, "g".to_string())]),
                HashMap::from([(ca, commit)]),
                BTreeMap::new(),
            ),
            views: HashMap::from([(ca, crate::SceneView::default())]),
            demos: HashMap::new(),
        };
        merge_with(&mut registry, &mut views, &mut demos, export);
        // Existing view (with 1 layout entry) should be preserved, not replaced.
        assert_eq!(views[&ca].layout.len(), 1);
    }

    #[test]
    fn is_gantz_path_matches_extension() {
        use std::path::Path;
        assert!(is_gantz_path(Path::new("foo.gantz")));
        assert!(is_gantz_path(Path::new("/tmp/bar.gantz")));
        assert!(is_gantz_path(Path::new("x.GANTZ")));
        assert!(!is_gantz_path(Path::new("foo.txt")));
        assert!(!is_gantz_path(Path::new("foo")));
        assert!(!is_gantz_path(Path::new("gantz")));
    }
}