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}