Skip to main content

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