hephaestus/composition/composition.rs
1//! The composition grid: [`Composition`], the [`Element`] tree it holds,
2//! [`CompositionError`], and the free combinators that build compositions.
3
4use crate::geometry::Size;
5use crate::layout::{Axis, Cell, Extent, Inset, Placement, Track};
6
7use super::build::{
8 build_composition_grid, build_single_patch, element_contains_patch_id, inset_is_zero,
9 BuildState,
10};
11use super::{CompositionLayout, Patch, PatchPlacement, Slot, Span, PANEL_COL, PANEL_ROW};
12
13/// A grid of [`Element`]s of size `rows × cols`. Per-panel-column widths and
14/// per-panel-row heights default to `Fr(1.0)`; override with
15/// [`Composition::widths`] / [`Composition::heights`].
16///
17/// Construct with [`beside`], [`stack`], [`grid`], or
18/// [`Composition::empty`] + [`Composition::place`] for spans.
19///
20/// Nested compositions are supported: an [`Element::Composition`] placed in
21/// a cell is simplified to the same canonical 13×16 anatomical block as a
22/// plain patch, with the inner composition's panel band collapsed into the
23/// outer block's panel cell and the inner border plots' chrome propagated
24/// to the outer block's chrome slots.
25pub struct Composition {
26 pub(super) placements: Vec<CompositionPlacement>,
27 pub(super) cols: usize,
28 pub(super) rows: usize,
29 pub(super) widths: Vec<Track>,
30 pub(super) heights: Vec<Track>,
31 /// Optional id for addressing chrome rects via
32 /// [`CompositionLayout::get`]. Set with [`Composition::id`].
33 /// `None` ⇒ chrome rects are placed but not retrievable by id.
34 pub(super) id: Option<String>,
35 /// Composition-level chrome slots (Title, Caption, axis titles, …).
36 /// When non-empty, the composition is treated as a "simplified plot":
37 /// its facets fill the panel cell of a canonical 13×16 anatomical
38 /// block, and these chrome slots sit at the canonical positions
39 /// surrounding it. Mirrors patchwork's `plot_annotation()`.
40 pub(super) chrome: Vec<PatchPlacement>,
41 /// When chrome is present, applies an aspect-ratio lock to the panel
42 /// cell (which contains the facets). Same wrapping as
43 /// [`Patch::aspect`].
44 pub(super) aspect: Option<(f64, f64)>,
45 /// Outer margin around the simplified canonical block. Only applied
46 /// when chrome is present.
47 pub(super) margin: Inset,
48 /// Inner padding inside the simplified canonical block. Only applied
49 /// when chrome is present.
50 pub(super) padding: Inset,
51 /// First construction error, if any. Builders record here instead
52 /// of panicking, so [`Composition::try_solve`] reports every
53 /// failure mode rather than only the ones that survive to solve
54 /// time; [`Composition::solve`] panics on it.
55 pub(super) error: Option<CompositionError>,
56}
57
58pub(crate) struct CompositionPlacement {
59 /// 1-indexed top-left cell within the composition.
60 pub(super) row: u16,
61 pub(super) col: u16,
62 pub(super) span: Span,
63 pub(super) element: Element,
64}
65
66/// Either a [`Patch`] or a (nested) [`Composition`].
67//
68// `Patch` carries the per-side margin + padding `Inset`s (6 `Option<Extent>`
69// each), so the `Patch` variant is ~ 400 bytes heavier than `Composition`.
70// Acceptable given the small number of `Element` values typically
71// constructed (one per patch in a composition); boxing margin/padding inside
72// `Patch` would add allocations on every construction.
73#[allow(clippy::large_enum_variant)]
74pub enum Element {
75 Patch(Patch),
76 Composition(Composition),
77}
78
79impl From<Patch> for Element {
80 fn from(p: Patch) -> Self {
81 Element::Patch(p)
82 }
83}
84
85impl From<Composition> for Element {
86 fn from(c: Composition) -> Self {
87 Element::Composition(c)
88 }
89}
90
91impl Composition {
92 /// Build an empty `rows × cols` composition filled with anonymous
93 /// spacers. Drop elements into specific cells with [`Self::place`].
94 pub fn empty(rows: usize, cols: usize) -> Composition {
95 let error = (rows < 1 || cols < 1).then_some(CompositionError::Degenerate { rows, cols });
96 // Clamp so the rest of the builder chain still has a coherent
97 // shape to record further errors against.
98 let (rows, cols) = (rows.max(1), cols.max(1));
99 Composition {
100 placements: Vec::new(),
101 cols,
102 rows,
103 widths: vec![Track::Fr(1.0); cols],
104 heights: vec![Track::Fr(1.0); rows],
105 id: None,
106 chrome: Vec::new(),
107 aspect: None,
108 margin: Inset::default(),
109 padding: Inset::default(),
110 error,
111 }
112 }
113
114 /// Record `err` unless an earlier one is already pending — the
115 /// first failure is the one that explains the rest.
116 fn fail(mut self, err: CompositionError) -> Self {
117 if self.error.is_none() {
118 self.error = Some(err);
119 }
120 self
121 }
122
123 /// Set the composition's id for chrome lookups. Required if you
124 /// want to retrieve chrome rects (Title, Caption, …) via
125 /// [`CompositionLayout::get`]. The composition's id is independent
126 /// of patch ids inside it.
127 pub fn id(mut self, id: impl Into<String>) -> Self {
128 self.id = Some(id.into());
129 self
130 }
131
132 /// Add a chrome slot to this composition. The composition becomes a
133 /// "simplified plot" wrapping its facets in the canonical 13×16
134 /// anatomical block; the slot lives at its canonical position
135 /// around the panel band (which contains the facets).
136 ///
137 /// Useful for giving a faceted plot a shared title / subtitle /
138 /// caption / axis title that spans all facets.
139 ///
140 /// Panics on [`Slot::Panel`] — the composition's facets fill the
141 /// panel.
142 pub fn slot(mut self, s: Slot, cell: Cell) -> Self {
143 if matches!(s, Slot::Panel) {
144 return self.fail(CompositionError::PanelSlot);
145 }
146 let (r, c, rs, cs) = s.placement();
147 self.chrome.push(PatchPlacement {
148 placement: Placement::at(r, c).span(rs, cs),
149 region: s.name().to_string(),
150 cell,
151 });
152 self
153 }
154
155 /// Escape hatch for composition-level chrome: place content at a
156 /// raw 1-indexed `(row, col)` within the canonical 13×16 block,
157 /// addressable as `(composition_id, region)`. Mirrors
158 /// [`Patch::place_at`].
159 ///
160 /// Panics if `(row, col, span)` includes the canonical panel cell
161 /// (row 9 col 7) — that cell is reserved for the composition's
162 /// facets.
163 pub fn place_at(
164 mut self,
165 region: impl Into<String>,
166 row: u16,
167 col: u16,
168 span: Span,
169 cell: Cell,
170 ) -> Self {
171 let end_row = row + span.rows - 1;
172 let end_col = col + span.cols - 1;
173 if row <= PANEL_ROW && end_row >= PANEL_ROW && col <= PANEL_COL && end_col >= PANEL_COL {
174 return self.fail(CompositionError::PanelCovered {
175 row: PANEL_ROW,
176 col: PANEL_COL,
177 });
178 }
179 self.chrome.push(PatchPlacement {
180 placement: Placement::at(row, col).span(span.rows, span.cols),
181 region: region.into(),
182 cell,
183 });
184 self
185 }
186
187 /// Lock every descendant's panel to an aspect ratio. The ratio
188 /// cascades depth-first into patches and nested compositions that
189 /// don't carry one of their own; a descendant with its own aspect
190 /// keeps it and blocks propagation past that node. Same per-patch
191 /// semantics as [`Patch::aspect`].
192 pub fn aspect(mut self, w: f64, h: f64) -> Self {
193 self.aspect = Some((w, h));
194 self
195 }
196
197 /// Per-side outer margin around the whole composition. Setting it
198 /// wraps the facets in a canonical block to carry the ring. Same
199 /// semantics as [`Patch::margin`].
200 pub fn margin(mut self, inset: Inset) -> Self {
201 self.margin = inset;
202 self
203 }
204
205 /// Convenience: identical margin on every side.
206 pub fn margin_all(self, length: Extent) -> Self {
207 self.margin(Inset::all(length))
208 }
209
210 /// Per-side inner padding between the composition's background edge
211 /// and its chrome. Setting it wraps the facets in a canonical block
212 /// to carry the ring. Same semantics as [`Patch::padding`].
213 pub fn padding(mut self, inset: Inset) -> Self {
214 self.padding = inset;
215 self
216 }
217
218 /// Convenience: identical padding on every side.
219 pub fn padding_all(self, length: Extent) -> Self {
220 self.padding(Inset::all(length))
221 }
222
223 /// The composition's id, if set with [`Self::id`]. Composition-level
224 /// chrome rects are keyed on `(id, region)`, so an unnamed
225 /// composition's chrome is placed but not retrievable.
226 pub fn composition_id(&self) -> Option<&str> {
227 self.id.as_deref()
228 }
229
230 /// Borrow the composition's aspect lock, if any.
231 pub fn aspect_ratio(&self) -> Option<(f64, f64)> {
232 self.aspect
233 }
234
235 /// Borrow the composition's outer margin inset.
236 pub fn margin_inset(&self) -> &Inset {
237 &self.margin
238 }
239
240 /// Borrow the composition's inner padding inset.
241 pub fn padding_inset(&self) -> &Inset {
242 &self.padding
243 }
244
245 /// Does this composition need wrapping in a canonical block? True
246 /// when it carries anything that block would hold: chrome cells, or
247 /// a margin / padding ring. An aspect is not among them — it
248 /// cascades into the descendants rather than locking a cell of the
249 /// composition's own.
250 pub(super) fn has_chrome(&self) -> bool {
251 !self.chrome.is_empty() || !inset_is_zero(&self.margin) || !inset_is_zero(&self.padding)
252 }
253
254 /// Place an element at 1-indexed `(row, col)` covering `span.rows ×
255 /// span.cols` cells. Re-placing into cells already covered by a previous
256 /// placement is allowed — later calls overlay earlier ones.
257 pub fn place(mut self, row: u16, col: u16, span: Span, element: impl Into<Element>) -> Self {
258 if row < 1 || col < 1 {
259 return self.fail(CompositionError::NotOneIndexed { row, col });
260 }
261 let end_row = (row + span.rows - 1) as usize;
262 let end_col = (col + span.cols - 1) as usize;
263 let (rows, cols) = (self.rows, self.cols);
264 if end_row > rows {
265 return self.fail(CompositionError::PlacementOverflow {
266 axis: Axis::Height,
267 end: end_row,
268 available: rows,
269 });
270 }
271 if end_col > cols {
272 return self.fail(CompositionError::PlacementOverflow {
273 axis: Axis::Width,
274 end: end_col,
275 available: cols,
276 });
277 }
278 self.placements.push(CompositionPlacement {
279 row,
280 col,
281 span,
282 element: element.into(),
283 });
284 self
285 }
286
287 /// Override the per-panel-column tracks. `tracks.len()` must equal
288 /// `self.cols`. Default is `Fr(1.0)` for every column.
289 pub fn widths(mut self, tracks: Vec<Track>) -> Self {
290 if tracks.len() != self.cols {
291 let found = tracks.len();
292 let expected = self.cols;
293 return self.fail(CompositionError::TrackCountMismatch {
294 axis: Axis::Width,
295 expected,
296 found,
297 });
298 }
299 self.widths = tracks;
300 self
301 }
302
303 /// Override the per-panel-row tracks. `tracks.len()` must equal
304 /// `self.rows`. Default is `Fr(1.0)` for every row.
305 pub fn heights(mut self, tracks: Vec<Track>) -> Self {
306 if tracks.len() != self.rows {
307 let found = tracks.len();
308 let expected = self.rows;
309 return self.fail(CompositionError::TrackCountMismatch {
310 axis: Axis::Height,
311 expected,
312 found,
313 });
314 }
315 self.heights = tracks;
316 self
317 }
318
319 /// `true` if any patch reachable from this composition (including
320 /// patches nested inside other patches' panels) has the given id.
321 /// Walks the element tree; anonymous patches are skipped.
322 pub fn contains_patch_id(&self, id: &str) -> bool {
323 self.placements
324 .iter()
325 .any(|p| element_contains_patch_id(&p.element, id))
326 }
327
328 /// Number of composition columns.
329 pub fn cols(&self) -> usize {
330 self.cols
331 }
332
333 /// Number of composition rows.
334 pub fn rows(&self) -> usize {
335 self.rows
336 }
337
338 /// Per-column tracks (panel column sizing). Extent always equals
339 /// [`Self::cols`]. Default `Fr(1.0)` per column unless the user set
340 /// [`Self::widths`].
341 pub fn widths_slice(&self) -> &[Track] {
342 &self.widths
343 }
344
345 /// Per-row tracks (panel row sizing). Extent always equals
346 /// [`Self::rows`].
347 pub fn heights_slice(&self) -> &[Track] {
348 &self.heights
349 }
350
351 /// Iterate `(row, col, span, &Element)` tuples for each placement.
352 /// Used by orchestrators (e.g. plot's `PlotComposition`) that walk
353 /// the composition tree to build a clone-friendly description.
354 pub fn placements(&self) -> impl Iterator<Item = (u16, u16, Span, &Element)> + '_ {
355 self.placements
356 .iter()
357 .map(|p| (p.row, p.col, p.span, &p.element))
358 }
359
360 /// Append a new column with `other` placed in the single row at
361 /// position `(1, cols + 1)`. Requires `self.rows == 1`. For
362 /// multi-row appends use [`Self::empty`] + [`Self::place`].
363 ///
364 /// Distinct from the free [`beside`] function, which builds a fresh
365 /// 1×2 composition rather than growing this one.
366 pub fn append_col(mut self, other: impl Into<Element>) -> Self {
367 if self.rows != 1 {
368 let extent = self.rows;
369 return self.fail(CompositionError::NotAppendable {
370 axis: Axis::Height,
371 extent,
372 });
373 }
374 self.cols += 1;
375 self.widths.push(Track::Fr(1.0));
376 self.placements.push(CompositionPlacement {
377 row: 1,
378 col: self.cols as u16,
379 span: Span::cell(),
380 element: other.into(),
381 });
382 self
383 }
384
385 /// Append a new row with `other` placed in the single column at
386 /// position `(rows + 1, 1)`. Requires `self.cols == 1`.
387 ///
388 /// Distinct from the free [`stack`] function, which builds a fresh
389 /// 2×1 composition rather than growing this one.
390 pub fn append_row(mut self, other: impl Into<Element>) -> Self {
391 if self.cols != 1 {
392 let extent = self.cols;
393 return self.fail(CompositionError::NotAppendable {
394 axis: Axis::Width,
395 extent,
396 });
397 }
398 self.rows += 1;
399 self.heights.push(Track::Fr(1.0));
400 self.placements.push(CompositionPlacement {
401 row: self.rows as u16,
402 col: 1,
403 span: Span::cell(),
404 element: other.into(),
405 });
406 self
407 }
408
409 /// Solve the composition in a `size`-sized viewport.
410 pub fn solve(self, size: Size, dpi: f64) -> CompositionLayout {
411 Element::Composition(self).solve(size, dpi)
412 }
413
414 /// Like [`Self::solve`] but returns an error instead of panicking.
415 ///
416 /// Reports construction errors the builders recorded (a placement
417 /// off the grid, a mismatched track list, `Slot::Panel` on a
418 /// composition) as well as solve-time ones (duplicate patch ids),
419 /// so this is the single entry point for validating a composition
420 /// built from untrusted input.
421 pub fn try_solve(self, size: Size, dpi: f64) -> Result<CompositionLayout, CompositionError> {
422 Element::Composition(self).try_solve(size, dpi)
423 }
424
425 /// The first construction error recorded by the builders, if any.
426 /// [`Self::try_solve`] surfaces the same thing; this reports it
427 /// without solving.
428 pub fn error(&self) -> Option<&CompositionError> {
429 self.error.as_ref()
430 }
431}
432
433impl Element {
434 /// The first construction error anywhere in this element's tree.
435 /// Walks nested compositions so an error recorded three levels
436 /// down still reaches the caller.
437 fn check_construction(&self) -> Result<(), CompositionError> {
438 match self {
439 Element::Patch(_) => Ok(()),
440 Element::Composition(c) => {
441 if let Some(e) = &c.error {
442 return Err(e.clone());
443 }
444 for p in &c.placements {
445 p.element.check_construction()?;
446 }
447 Ok(())
448 }
449 }
450 }
451
452 /// Solve this element as the root of a layout.
453 ///
454 /// # Panics
455 ///
456 /// On any [`CompositionError`] — a construction mistake the
457 /// builders recorded, or a duplicate patch id. Use
458 /// [`Self::try_solve`] to inspect it instead.
459 pub fn solve(self, size: Size, dpi: f64) -> CompositionLayout {
460 match self.try_solve(size, dpi) {
461 Ok(layout) => layout,
462 Err(e) => panic!("composition error: {e} — use try_solve to handle this"),
463 }
464 }
465
466 /// Like [`Self::solve`] but returns errors instead of panicking.
467 pub fn try_solve(self, size: Size, dpi: f64) -> Result<CompositionLayout, CompositionError> {
468 self.check_construction()?;
469 let mut state = BuildState::new();
470 let root_id = state.alloc_id();
471 let grid = match self {
472 Element::Patch(p) => build_single_patch(p, root_id, &mut state)?,
473 Element::Composition(c) => build_composition_grid(c, root_id, &mut state, None)?,
474 };
475 let layout = grid.solve(size, dpi);
476 Ok(CompositionLayout {
477 layout,
478 regions: state.regions,
479 })
480 }
481}
482
483/// Errors produced by [`Composition::try_solve`].
484#[derive(Debug, Clone)]
485pub enum CompositionError {
486 /// Two patches reachable from the root carry the same id.
487 DuplicateId(String),
488 /// [`Composition::empty`] was given a zero row or column count.
489 Degenerate { rows: usize, cols: usize },
490 /// [`Composition::slot`] was given [`Slot::Panel`]. A composition's
491 /// facets fill the panel; there is no panel of its own to populate.
492 PanelSlot,
493 /// [`Composition::place_at`] covered the panel cell, which the
494 /// facets occupy.
495 PanelCovered { row: u16, col: u16 },
496 /// A placement used a 0 row or column. Placements are 1-indexed.
497 NotOneIndexed { row: u16, col: u16 },
498 /// A placement reached past the composition's extent on `axis`.
499 PlacementOverflow {
500 axis: Axis,
501 end: usize,
502 available: usize,
503 },
504 /// An explicit track list didn't match the composition's extent on
505 /// `axis`.
506 TrackCountMismatch {
507 axis: Axis,
508 expected: usize,
509 found: usize,
510 },
511 /// [`Composition::append_col`] / [`append_row`](Composition::append_row)
512 /// require a single-row / single-column composition respectively.
513 NotAppendable { axis: Axis, extent: usize },
514 /// [`grid`] was given a cell count that isn't `rows * cols`.
515 CellCountMismatch {
516 rows: usize,
517 cols: usize,
518 found: usize,
519 },
520}
521
522impl std::fmt::Display for CompositionError {
523 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
524 match self {
525 CompositionError::DuplicateId(id) => {
526 write!(f, "duplicate patch id: {id:?}")
527 }
528 CompositionError::Degenerate { rows, cols } => {
529 write!(f, "composition must be at least 1×1, got {rows}×{cols}")
530 }
531 CompositionError::PanelSlot => write!(
532 f,
533 "Composition::slot does not accept Slot::Panel; the composition's facets fill it"
534 ),
535 CompositionError::PanelCovered { row, col } => write!(
536 f,
537 "Composition::place_at cannot cover the panel cell (row {row}, col {col}); \
538 the facets fill it"
539 ),
540 CompositionError::NotOneIndexed { row, col } => {
541 write!(f, "composition placement is 1-indexed, got ({row}, {col})")
542 }
543 CompositionError::PlacementOverflow {
544 axis,
545 end,
546 available,
547 } => write!(
548 f,
549 "placement reaches {axis:?} {end} but the composition has {available}"
550 ),
551 CompositionError::TrackCountMismatch {
552 axis,
553 expected,
554 found,
555 } => write!(
556 f,
557 "{axis:?} track list must have {expected} entries, got {found}"
558 ),
559 CompositionError::NotAppendable { axis, extent } => write!(
560 f,
561 "appending along {axis:?} requires a single-track composition, got {extent}"
562 ),
563 CompositionError::CellCountMismatch { rows, cols, found } => write!(
564 f,
565 "grid({rows}, {cols}) needs {} cells, got {found}",
566 rows * cols
567 ),
568 }
569 }
570}
571
572impl std::error::Error for CompositionError {}
573
574// ─── Free-function combinators ───────────────────────────────────────────────
575
576/// Place `a` and `b` side by side in a 1×2 composition.
577pub fn beside(a: impl Into<Element>, b: impl Into<Element>) -> Composition {
578 grid(1, 2, vec![a.into(), b.into()])
579}
580
581/// Stack `a` on top of `b` in a 2×1 composition.
582pub fn stack(a: impl Into<Element>, b: impl Into<Element>) -> Composition {
583 grid(2, 1, vec![a.into(), b.into()])
584}
585
586/// Build a `rows × cols` composition from `cells` in row-major order.
587/// `cells.len()` must equal `rows * cols`.
588pub fn grid(rows: usize, cols: usize, cells: Vec<Element>) -> Composition {
589 let found = cells.len();
590 let mut c = Composition::empty(rows, cols);
591 if found != rows * cols {
592 return c.fail(CompositionError::CellCountMismatch { rows, cols, found });
593 }
594 for (i, element) in cells.into_iter().enumerate() {
595 let r = (i / cols) as u16 + 1;
596 let col = (i % cols) as u16 + 1;
597 c.placements.push(CompositionPlacement {
598 row: r,
599 col,
600 span: Span::cell(),
601 element,
602 });
603 }
604 c
605}
606
607/// An anonymous spacer patch — empty, alignment-only, not addressable.
608pub fn spacer() -> Patch {
609 Patch::anonymous()
610}
611
612/// A patch wrapping `cell` in its Panel slot. Addressable as `(id, "panel")`.
613pub fn wrap(id: impl Into<String>, cell: Cell) -> Patch {
614 Patch::new(id).slot(Slot::Panel, cell)
615}