Skip to main content

blockworx_editor/
title_block.rs

1//! The drawing's title block: what a printed sheet states about the drawing
2//! it carries, and how that statement is arranged.
3//!
4//! It says what a reader needs in order to know what they are looking at: what
5//! the document is, which revision, who wrote it and when, and which level of
6//! it is on the sheet.
7//!
8//! This is **content, not chrome**: the block belongs to the sheet,
9//! inside the drawing border, because it prints. The canvas itself carries no
10//! copy — the name reads in the document bar, the path in the status strip's
11//! breadcrumb, and the rev, author and date in the history panel's own rows.
12
13use blockworx_doc::rev::Rev;
14
15use blockworx_store::stamp::Provenance;
16
17use crate::{content_path, theme::Theme};
18
19/// What a title block states about a drawing. The strings are owned: the
20/// session hands the block a stamp rather than a borrow of itself, so the same
21/// stamp can be built where the document index is borrowed — and asked for by
22/// a test, or carried into an export.
23#[derive(Clone, Default)]
24pub struct TitleBlock {
25    /// What the document calls itself — the name in the window title, and the
26    /// one the printed sheet's block carries.
27    pub name: String,
28    /// The name this session's commits are attributed to.
29    pub author: String,
30    /// The revision on the canvas — the current one, or the one the time
31    /// machine is showing.
32    pub rev: Rev,
33    /// The date [`Self::rev`] was written, read off the log rather than off a
34    /// clock, so an export of a rev says the same thing every time it is
35    /// taken. `None` for a scratch session, whose log carries no wall times —
36    /// and then the block simply has no Date row.
37    pub date: Option<String>,
38    /// Where this session's document came from, when it was opened from an
39    /// export rather than authored here. Advisory and session-only: it is
40    /// never written into the log.
41    pub from: Option<Provenance>,
42}
43
44/// What a cell's value is worth, for the painter that has to color it: the
45/// document's own name is the one line a sheet sets apart from the rest.
46#[derive(Clone, Copy, PartialEq, Eq, Debug)]
47pub enum Emphasis {
48    Title,
49    Body,
50}
51
52/// What a cell says. A path is kept as its segments rather than as the joined
53/// string because the printed sheet links each ancestor segment to that
54/// ancestor's page, which means knowing where one name ends and the next
55/// begins.
56#[derive(Clone, PartialEq, Eq, Debug)]
57pub enum Value {
58    Text(String),
59    Path(Vec<String>),
60}
61
62impl Value {
63    /// The value as one run of text — what both painters set.
64    pub fn text(&self) -> String {
65        match self {
66            Value::Text(text) => text.clone(),
67            Value::Path(segments) => content_path::join(segments),
68        }
69    }
70}
71
72/// One cell of the block: its caption, what it says, how it is set, and what
73/// it has no room for.
74#[derive(Clone, PartialEq, Eq, Debug)]
75pub struct Cell {
76    pub caption: &'static str,
77    pub value: Value,
78    pub emphasis: Emphasis,
79}
80
81impl Cell {
82    fn new(caption: &'static str, text: String) -> Cell {
83        Cell {
84            caption,
85            value: Value::Text(text),
86            emphasis: Emphasis::Body,
87        }
88    }
89}
90
91/// The block's arrangement: an ECAD-style two-column head — what the drawing
92/// *is* on the left, which revision of it on the right — over lines wide
93/// enough for a path to run across without squeezing the head.
94///
95/// A column is a list, so a column with nothing more to say simply ends; a
96/// hole in the middle of one is not representable.
97#[derive(Clone, PartialEq, Eq, Debug)]
98pub struct Grid {
99    pub left: Vec<Cell>,
100    pub right: Vec<Cell>,
101    pub wide: Vec<Cell>,
102}
103
104impl Grid {
105    /// How many lines the head takes.
106    pub fn head_rows(&self) -> usize {
107        self.left.len().max(self.right.len())
108    }
109
110    /// Every cell in reading order — the whole of what the block says, for a
111    /// reader that does not care which column it was in.
112    #[cfg_attr(not(test), allow(dead_code))]
113    pub fn cells(&self) -> impl Iterator<Item = &Cell> {
114        (0..self.head_rows())
115            .flat_map(|row| [self.left.get(row), self.right.get(row)])
116            .flatten()
117            .chain(self.wide.iter())
118    }
119}
120
121/// What the Path row says at the document root.
122pub const ROOT_PATH: &str = "Root";
123
124impl TitleBlock {
125    /// The block's cells for the level at `path`, named outermost first (see
126    /// [`content_path::segments`]). The document root names no level of its
127    /// own, so its path is spelled [`ROOT_PATH`]: a sheet always says where it
128    /// is.
129    pub fn grid(&self, path: &[String]) -> Grid {
130        let mut right = vec![Cell::new(REV_CAPTION, self.rev.get().to_string())];
131        if let Some(date) = &self.date {
132            right.push(Cell::new("Date:", date.clone()));
133        }
134        let path = if path.is_empty() {
135            vec![ROOT_PATH.to_owned()]
136        } else {
137            path.to_vec()
138        };
139        let mut wide = vec![Cell {
140            caption: "Path:",
141            value: Value::Path(path),
142            emphasis: Emphasis::Body,
143        }];
144        if let Some(from) = &self.from {
145            wide.push(Cell {
146                caption: "From:",
147                value: Value::Text(from.line()),
148                emphasis: Emphasis::Body,
149            });
150        }
151        Grid {
152            left: vec![
153                Cell {
154                    caption: "Diagram:",
155                    value: Value::Text(self.name.clone()),
156                    emphasis: Emphasis::Title,
157                },
158                Cell::new("Author:", self.author.clone()),
159            ],
160            right,
161            wide,
162        }
163    }
164}
165
166/// The one size the block sets everything in, captions and values alike: the
167/// canvas's own block-title size, so the block reads as part of the drawing
168/// rather than as chrome bolted beside it. The printed sheet takes the same
169/// size at the document scale.
170pub fn text_size(theme: &Theme) -> f32 {
171    theme.font_sizes().title
172}
173
174/// The cell the rev is stated in.
175const REV_CAPTION: &str = "Rev:";
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use blockworx_doc::fixtures::rev;
181
182    pub(crate) fn block(rev: Rev) -> TitleBlock {
183        TitleBlock {
184            name: "motor-controller".to_owned(),
185            author: "ada".to_owned(),
186            rev,
187            date: Some("2026-08-30".to_owned()),
188            from: None,
189        }
190    }
191
192    /// The path `drawn` shows, as its own segments.
193    pub(crate) fn path_names() -> Vec<String> {
194        ["top", "Thing 1", "Core"]
195            .map(str::to_owned)
196            .into_iter()
197            .collect()
198    }
199
200    fn cell(caption: &'static str, text: &str) -> Cell {
201        Cell::new(caption, text.to_owned())
202    }
203
204    /// The whole of what a title block says, and how it is arranged: what the
205    /// drawing *is* down the left, which revision of it down the right, the
206    /// level it shows across the bottom. Every painter is handed this one
207    /// arrangement, so two of them cannot say different things.
208    #[test]
209    fn the_grid_names_the_document_its_revision_and_the_level_drawn() {
210        assert_eq!(
211            block(rev(41)).grid(&path_names()),
212            Grid {
213                left: vec![
214                    Cell {
215                        caption: "Diagram:",
216                        value: Value::Text("motor-controller".to_owned()),
217                        emphasis: Emphasis::Title,
218                    },
219                    cell("Author:", "ada"),
220                ],
221                right: vec![cell(REV_CAPTION, "41"), cell("Date:", "2026-08-30")],
222                wide: vec![Cell {
223                    caption: "Path:",
224                    value: Value::Path(path_names()),
225                    emphasis: Emphasis::Body,
226                }],
227            },
228        );
229    }
230
231    /// A scratch session's log carries no wall times, so there is no date to
232    /// state — and the layout simply has one line fewer rather than a row
233    /// saying nothing. The document root still says where it is.
234    #[test]
235    fn a_missing_date_takes_its_line_and_the_root_is_named() {
236        let mut block = block(rev(3));
237        block.date = None;
238        let grid = block.grid(&[]);
239        assert_eq!(grid.right.len(), 1, "{:?}", grid.right);
240        assert_eq!(grid.right[0].caption, REV_CAPTION);
241        assert_eq!(
242            grid.wide,
243            vec![Cell {
244                caption: "Path:",
245                value: Value::Path(vec![ROOT_PATH.to_owned()]),
246                emphasis: Emphasis::Body,
247            }],
248        );
249        assert_eq!(
250            grid.head_rows(),
251            2,
252            "the head is as tall as its longer column",
253        );
254    }
255
256    /// A session opened from an export says so, naming the rev and the
257    /// document it came from.
258    #[test]
259    fn a_document_opened_from_an_export_names_where_it_came_from() {
260        let mut block = block(rev(1));
261        block.from = Some(Provenance {
262            document: "motor-controller".to_owned(),
263            rev: rev(23),
264            author: "ada".to_owned(),
265            tags: vec!["Initial Draft".to_owned()],
266        });
267        let grid = block.grid(&path_names());
268        let from = grid.wide.last().expect("the block has wide lines");
269        assert_eq!(from.caption, "From:");
270        assert_eq!(from.value.text(), "Rev 23 of motor-controller");
271        assert_eq!(
272            grid.right[0].value.text(),
273            "1",
274            "the Rev cell still names the rev on the canvas, not the source's",
275        );
276    }
277}