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}