Skip to main content

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}