Skip to main content

blockworx/tools/
content_path.rs

1//! The content path: the chain of block names from the document root down to
2//! the level being drawn, as a plain string — `top/Thing 1/Core`.
3//!
4//! It reads along the status strip as a breadcrumb, is printed across the
5//! bottom of every PDF sheet, and is parsed back by the command palette's
6//! `go <path>`, so a path can be copied out of one window and pasted into
7//! another to navigate there.
8
9use crate::path::{BlockPath, Scope, child_blocks};
10use blockworx_doc::{document::IndexedDocument, id::BlockId};
11
12/// The document read a path walks: the whole document with its index, since
13/// resolving a name needs each scope's children.
14type Doc<'a> = IndexedDocument<'a>;
15
16/// What separates two names in a path string.
17const SEPARATOR: char = '/';
18/// The same separator, for a caller measuring what it costs in a line.
19pub const SEPARATOR_STR: &str = "/";
20
21/// A block's name for path purposes: its title, or its id when untitled. The
22/// navigator's richer `name/type` label would only make a path harder to read
23/// and to retype.
24fn name_of(document: &Doc<'_>, id: BlockId) -> String {
25    document
26        .doc
27        .block(&id)
28        .map(|block| block.title.name.trim().to_owned())
29        .filter(|name| !name.is_empty())
30        .unwrap_or_else(|| id.to_string())
31}
32
33/// The path's names, outermost first. Every segment is a block the path
34/// descended into — the document root is the unnamed scope they hang from,
35/// so it contributes nothing and the empty path (the root itself) has no
36/// segments at all.
37///
38/// The segments rather than the joined string: the printed sheet links each
39/// ancestor segment to that ancestor's page, so it needs to know where one
40/// name ends and the next begins.
41pub fn segments(document: &Doc<'_>, path: &BlockPath) -> Vec<String> {
42    path.segments()
43        .iter()
44        .map(|&id| name_of(document, id))
45        .collect()
46}
47
48/// The path as a string, outermost first: `top/Thing 1/Core`.
49pub fn to_string(document: &Doc<'_>, path: &BlockPath) -> String {
50    join(&segments(document, path))
51}
52
53/// The one spelling of a path: its names, separated.
54pub fn join(segments: &[String]) -> String {
55    segments.join(&SEPARATOR.to_string())
56}
57
58/// Resolve a path string back to a [`BlockPath`], matching names down the tree
59/// from the document root. Names match case-insensitively after trimming, and
60/// the first sibling that matches wins. `None` if any name names nothing.
61pub fn parse(document: &Doc<'_>, text: &str) -> Option<BlockPath> {
62    let names = text
63        .split(SEPARATOR)
64        .map(str::trim)
65        .filter(|name| !name.is_empty());
66    let mut path = BlockPath::empty();
67    let mut current = Scope::Root;
68    for name in names {
69        let child = child_blocks(document, current)
70            .into_iter()
71            .find(|&id| name_of(document, id).eq_ignore_ascii_case(name))?;
72        path.push(child);
73        current = Scope::Block(child);
74    }
75    Some(path)
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use crate::widget::test_fixtures::{self as fx, Scene};
82    use blockworx_doc::fixtures::block_id;
83    use blockworx_geom::{Rect, pos2};
84
85    fn body() -> Rect {
86        Rect::from_min_max(pos2(0.0, 0.0), pos2(60.0, 60.0))
87    }
88
89    /// `top → Thing 1 → Core`, returning the ids in path order.
90    fn nested() -> (Scene, Vec<BlockId>) {
91        let scene = Scene::new(vec![
92            fx::block_in(1, Scope::Root, body()),
93            fx::titled(1, "top"),
94            fx::block_in(2, Scope::Block(block_id(1)), body()),
95            fx::titled(2, "Thing 1"),
96            fx::block_in(3, Scope::Block(block_id(2)), body()),
97            fx::titled(3, "Core"),
98        ]);
99        (scene, vec![block_id(1), block_id(2), block_id(3)])
100    }
101
102    fn path_of(ids: &[BlockId]) -> BlockPath {
103        let mut path = BlockPath::empty();
104        for &id in ids {
105            path.push(id);
106        }
107        path
108    }
109
110    #[test]
111    fn a_path_reads_as_names_from_the_root_down() {
112        let (mut scene, ids) = nested();
113        let indexed = scene.indexed();
114        assert_eq!(to_string(&indexed, &path_of(&ids)), "top/Thing 1/Core");
115        assert_eq!(
116            to_string(&indexed, &BlockPath::empty()),
117            "",
118            "the document root is the unnamed scope the path hangs from"
119        );
120    }
121
122    /// An untitled block still names a level: its id stands in, so the string
123    /// never has an empty segment to parse back.
124    #[test]
125    fn an_untitled_block_falls_back_to_its_id() {
126        let id = block_id(1);
127        let mut scene = Scene::new(vec![
128            fx::block_in(1, Scope::Root, body()),
129            fx::titled(1, ""),
130        ]);
131        let indexed = scene.indexed();
132        let text = to_string(&indexed, &path_of(&[id]));
133        assert_eq!(text, id.to_string());
134        assert_eq!(parse(&indexed, &text), Some(path_of(&[id])));
135    }
136
137    /// What the display shows round-trips back to the path it came from —
138    /// the whole point of copying the string out.
139    #[test]
140    fn a_displayed_path_parses_back_to_itself() {
141        let (mut scene, ids) = nested();
142        let indexed = scene.indexed();
143        let path = path_of(&ids);
144        assert_eq!(
145            parse(&indexed, &to_string(&indexed, &path)),
146            Some(path.clone())
147        );
148        // Spacing and case are forgiven.
149        assert_eq!(parse(&indexed, " top / thing 1 / core "), Some(path));
150        assert_eq!(parse(&indexed, "top"), Some(path_of(&ids[..1])));
151    }
152
153    /// The document root names no level, so it contributes no segments for a
154    /// breadcrumb — or a printed sheet — to draw a line from.
155    #[test]
156    fn the_root_contributes_no_segments() {
157        let (mut scene, _) = nested();
158        let indexed = scene.indexed();
159        assert!(segments(&indexed, &BlockPath::empty()).is_empty());
160    }
161
162    #[test]
163    fn a_name_that_names_nothing_fails_to_parse() {
164        let (mut scene, _) = nested();
165        let indexed = scene.indexed();
166        assert_eq!(parse(&indexed, "top/Thing 1/Nope"), None);
167        assert_eq!(
168            parse(&indexed, "Core"),
169            None,
170            "names resolve level by level"
171        );
172    }
173}