Skip to main content

concinnity_core/components/
layout_container.rs

1// Row-based label layout container schema.
2
3use crate::ecs::asset_id::AssetId;
4use alloc::vec::Vec;
5
6/// Horizontal placement of a row's labels within the container's content width
7/// (the width of the widest row). Ignored when a row is as wide as the content.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
9#[serde(rename_all = "kebab-case")]
10pub enum Justify {
11    /// Pack labels against the left edge (the default).
12    #[default]
13    Left,
14    /// Center the row within the content width.
15    Center,
16    /// Pack labels against the right edge.
17    Right,
18    /// Spread the row across the full content width, distributing the slack
19    /// evenly between labels. A single-label row falls back to `Left`.
20    SpaceBetween,
21}
22
23/// One horizontal row of labels inside a `LayoutContainer`.
24#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
25#[serde(default)]
26pub struct LayoutRow {
27    /// The [TextLabel](#textlabel)s in this row, laid out left to right. Their
28    /// own `x`/`y` are ignored: the container positions them.
29    pub cols: Vec<AssetId>,
30    /// How this row is placed within the container's content width.
31    pub justify: Justify,
32}
33
34impl Default for LayoutRow {
35    fn default() -> Self {
36        Self {
37            cols: Vec::new(),
38            justify: Justify::Left,
39        }
40    }
41}
42
43/// Positions a set of [TextLabel](#textlabel)s as a stack of rows, so a HUD does
44/// not have to hand-place every chip. Each row lays its labels out left to
45/// right; rows stack top to bottom. The container owns the labels' on-screen
46/// position: the labels keep their own styling (font, colour, background,
47/// padding) but their `x`/`y` are overwritten each frame.
48///
49/// Sizing is content-driven: a label is measured at its current text, so the
50/// layout reflows as live HUD values change width. A row with a single label
51/// sits on its own line beneath the previous row, which is how a wide chip
52/// (e.g. a multi-pass timing line) ends up spanning the width of the row above.
53///
54/// Labels referenced by `cols` are matched by name; a label whose font is not
55/// loaded, or which is hidden, is skipped and reserves no space.
56///
57/// ```rust
58/// # use concinnity_core::components::LayoutContainer;
59/// LayoutContainer {
60///     x: 10.0,
61///     y: 10.0,
62///     col_gap: 6.0,
63///     row_gap: 6.0,
64///     ..Default::default()
65/// };
66/// ```
67#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
68#[serde(default)]
69pub struct LayoutContainer {
70    /// Left edge of the container in window pixels.
71    pub x: f32,
72    /// Top edge of the container in window pixels.
73    pub y: f32,
74    /// Pixels between adjacent labels in a row, measured between their
75    /// background boxes.
76    pub col_gap: f32,
77    /// Pixels between adjacent rows, measured between their background boxes.
78    pub row_gap: f32,
79    /// Rows of labels, top to bottom.
80    pub rows: Vec<LayoutRow>,
81    /// When false, the container leaves its labels where they are instead of
82    /// repositioning them.
83    pub visible: bool,
84}
85
86impl Default for LayoutContainer {
87    fn default() -> Self {
88        Self {
89            x: 10.0,
90            y: 10.0,
91            col_gap: 6.0,
92            row_gap: 6.0,
93            rows: Vec::new(),
94            visible: true,
95        }
96    }
97}
98
99/// A label's measured extent, used by [`LayoutContainer::layout`] to place it.
100/// `w`/`h` are the full background-box size in pixels (the text extent grown by
101/// `pad` on every side). `pad` is the horizontal inset from the box's left edge
102/// to the text origin. `top_inset` is the vertical inset from the box's top edge
103/// down to the text origin (the label's `y`); it is distinct from `pad` because
104/// the box hugs the visible glyphs, which sit below the text origin.
105#[derive(Debug, Clone, Copy, PartialEq)]
106pub struct LabelBox {
107    /// Background-box width in pixels.
108    pub w: f32,
109    /// Background-box height in pixels.
110    pub h: f32,
111    /// Horizontal inset from the box's left edge to the text origin.
112    pub pad: f32,
113    /// Vertical inset from the box's top edge down to the text origin.
114    pub top_inset: f32,
115}
116
117/// The resolved top-left text origin for one label.
118#[derive(Debug, Clone, Copy, PartialEq)]
119pub struct LabelPlacement {
120    /// The label this placement is for.
121    pub id: AssetId,
122    /// Text-origin x in pixels from the window's top-left.
123    pub x: f32,
124    /// Text-origin y in pixels from the window's top-left.
125    pub y: f32,
126}
127
128impl LayoutContainer {
129    /// Resolve the text origin for every label in the container.
130    ///
131    /// `size_of` returns a label's measured box, or `None` to drop it from the
132    /// layout (unknown label, unloaded font, hidden). The result is pure
133    /// geometry: callers measure with their font metrics, this places the
134    /// boxes. Boxes within a row are laid edge-to-edge with `col_gap` between
135    /// them; rows are stacked with `row_gap` between them.
136    pub fn layout(&self, size_of: impl Fn(AssetId) -> Option<LabelBox>) -> Vec<LabelPlacement> {
137        let mut out = Vec::new();
138        self.layout_into(size_of, &mut out);
139        out
140    }
141
142    /// [`LayoutContainer::layout`] writing into a caller-owned buffer, so a
143    /// per-frame caller reuses its capacity. Clears `out` first.
144    pub fn layout_into(
145        &self,
146        size_of: impl Fn(AssetId) -> Option<LabelBox>,
147        out: &mut Vec<LabelPlacement>,
148    ) {
149        out.clear();
150        // A row's measurable width: cells laid edge-to-edge with the column
151        // gap, dropping unknown labels.
152        let row_width = |row: &LayoutRow| -> f32 {
153            let mut sum = 0.0_f32;
154            let mut n = 0usize;
155            for &id in &row.cols {
156                if let Some(b) = size_of(id) {
157                    sum += b.w;
158                    n += 1;
159                }
160            }
161            if n == 0 {
162                0.0
163            } else {
164                sum + self.col_gap * (n - 1) as f32
165            }
166        };
167
168        // Content width is the widest row; narrower rows justify within it.
169        let content_w = self.rows.iter().map(row_width).fold(0.0_f32, f32::max);
170
171        let mut y_cursor = self.y;
172        for row in &self.rows {
173            let mut n = 0usize;
174            let mut row_h = 0.0_f32;
175            for &id in &row.cols {
176                if let Some(b) = size_of(id) {
177                    n += 1;
178                    row_h = row_h.max(b.h);
179                }
180            }
181            if n > 0 {
182                let rw = row_width(row);
183                let slack = (content_w - rw).max(0.0);
184                let (start, gap) = match row.justify {
185                    Justify::Left => (0.0, self.col_gap),
186                    Justify::Right => (slack, self.col_gap),
187                    Justify::Center => (slack / 2.0, self.col_gap),
188                    Justify::SpaceBetween => {
189                        if n > 1 {
190                            (0.0, self.col_gap + slack / (n - 1) as f32)
191                        } else {
192                            (0.0, self.col_gap)
193                        }
194                    }
195                };
196                let mut x_cursor = self.x + start;
197                for &id in &row.cols {
198                    let Some(b) = size_of(id) else {
199                        continue;
200                    };
201                    // Box occupies [x_cursor, x_cursor + b.w]; the text origin the
202                    // renderer wants is inset from the box's top-left by the
203                    // horizontal padding and the (possibly different) vertical
204                    // inset, since the box hugs the visible glyphs.
205                    out.push(LabelPlacement {
206                        id,
207                        x: x_cursor + b.pad,
208                        y: y_cursor + b.top_inset,
209                    });
210                    x_cursor += b.w + gap;
211                }
212            }
213            y_cursor += row_h + self.row_gap;
214        }
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use alloc::vec;
222
223    // A label 10 wide and 20 tall per unit of its id, with no padding, so a
224    // placement's x/y is the box corner and widths stay easy to add up.
225    fn box_of(id: AssetId) -> Option<LabelBox> {
226        Some(LabelBox {
227            w: 10.0 * id.0 as f32,
228            h: 20.0,
229            pad: 0.0,
230            top_inset: 0.0,
231        })
232    }
233
234    fn row(justify: Justify, cols: &[u32]) -> LayoutRow {
235        LayoutRow {
236            cols: cols.iter().copied().map(AssetId).collect(),
237            justify,
238        }
239    }
240
241    fn container(rows: Vec<LayoutRow>) -> LayoutContainer {
242        LayoutContainer {
243            x: 0.0,
244            y: 0.0,
245            col_gap: 2.0,
246            row_gap: 4.0,
247            rows,
248            ..LayoutContainer::default()
249        }
250    }
251
252    #[test]
253    fn defaults_place_an_empty_container_in_the_top_left() {
254        let c = LayoutContainer::default();
255        assert_eq!((c.x, c.y), (10.0, 10.0));
256        assert_eq!((c.col_gap, c.row_gap), (6.0, 6.0));
257        assert!(c.visible);
258        assert!(c.rows.is_empty());
259        assert!(c.layout(box_of).is_empty());
260        assert_eq!(LayoutRow::default().justify, Justify::Left);
261        assert_eq!(Justify::default(), Justify::Left);
262    }
263
264    #[test]
265    fn a_row_lays_its_labels_out_edge_to_edge_with_the_column_gap() {
266        let c = container(vec![row(Justify::Left, &[1, 2])]);
267        let out = c.layout(box_of);
268        assert_eq!(out.len(), 2);
269        assert_eq!((out[0].x, out[0].y), (0.0, 0.0));
270        // Second box starts after the first box's width plus the gap.
271        assert_eq!(out[1].x, 12.0);
272    }
273
274    #[test]
275    fn rows_stack_by_the_tallest_box_plus_the_row_gap() {
276        let c = container(vec![row(Justify::Left, &[1]), row(Justify::Left, &[1])]);
277        let out = c.layout(box_of);
278        assert_eq!(out[0].y, 0.0);
279        assert_eq!(out[1].y, 24.0);
280    }
281
282    #[test]
283    fn a_narrow_row_justifies_within_the_widest_row() {
284        // Wide row is 10 + 2 + 20 = 32; narrow row is 10, so 22 of slack.
285        let rows = |j| vec![row(Justify::Left, &[1, 2]), row(j, &[1])];
286        let x_of_narrow = |j| container(rows(j)).layout(box_of)[2].x;
287        assert_eq!(x_of_narrow(Justify::Left), 0.0);
288        assert_eq!(x_of_narrow(Justify::Center), 11.0);
289        assert_eq!(x_of_narrow(Justify::Right), 22.0);
290        // A single-label row has nothing to spread between, so it packs left.
291        assert_eq!(x_of_narrow(Justify::SpaceBetween), 0.0);
292    }
293
294    #[test]
295    fn space_between_spreads_the_slack_across_the_gaps() {
296        let c = container(vec![
297            row(Justify::Left, &[4]),
298            row(Justify::SpaceBetween, &[1, 1, 1]),
299        ]);
300        // Wide row is 40; the three narrow boxes are 30 + 2 gaps = 34, so 6 of
301        // slack split over 2 gaps: each gap grows from 2 to 5.
302        let out = c.layout(box_of);
303        assert_eq!(out[1].x, 0.0);
304        assert_eq!(out[2].x, 15.0);
305        assert_eq!(out[3].x, 30.0);
306    }
307
308    #[test]
309    fn a_label_that_cannot_be_measured_is_dropped_and_reserves_no_space() {
310        // An unknown label, an unloaded font, or a hidden label measures to None.
311        let c = container(vec![row(Justify::Left, &[1, 2, 3])]);
312        let out = c.layout(|id| if id.0 == 2 { None } else { box_of(id) });
313        assert_eq!(out.len(), 2);
314        assert_eq!(out[0].id, AssetId(1));
315        assert_eq!(out[1].id, AssetId(3));
316        // Box 3 follows box 1 directly, as though box 2 was never declared.
317        assert_eq!(out[1].x, 12.0);
318    }
319
320    #[test]
321    fn an_empty_row_still_advances_the_cursor_by_the_row_gap() {
322        let c = container(vec![row(Justify::Left, &[]), row(Justify::Left, &[1])]);
323        let out = c.layout(box_of);
324        assert_eq!(out.len(), 1);
325        assert_eq!(out[0].y, 4.0);
326    }
327
328    #[test]
329    fn padding_insets_the_text_origin_from_the_box_corner() {
330        // The box hugs the visible glyphs, which sit below the text origin, so
331        // the vertical inset is its own number rather than the padding.
332        let c = container(vec![row(Justify::Left, &[1])]);
333        let out = c.layout(|id| {
334            Some(LabelBox {
335                pad: 3.0,
336                top_inset: 7.0,
337                ..box_of(id).unwrap()
338            })
339        });
340        assert_eq!((out[0].x, out[0].y), (3.0, 7.0));
341    }
342
343    #[test]
344    fn rows_parse_from_authored_args_and_round_trip_through_postcard() {
345        crate::test_support::install_resolvers();
346        let c: LayoutContainer = serde_json::from_str(
347            r#"{"x":10,"y":10,"col_gap":6,"row_gap":6,
348                "rows":[{"cols":["fps_chip","ev_chip"],"justify":"space-between"},
349                        {"cols":["passes_chip"]}]}"#,
350        )
351        .unwrap();
352        assert_eq!(c.rows.len(), 2);
353        assert_eq!(c.rows[0].justify, Justify::SpaceBetween);
354        assert_eq!(c.rows[1].justify, Justify::Left);
355        assert_eq!(c.rows[0].cols, [AssetId(8), AssetId(7)]);
356
357        let bytes = postcard::to_allocvec(&c).unwrap();
358        let back: LayoutContainer = postcard::from_bytes(&bytes).unwrap();
359        assert_eq!(back.rows[0].justify, Justify::SpaceBetween);
360        assert_eq!(back.rows[1].cols, [AssetId(11)]);
361    }
362
363    #[test]
364    fn layout_into_clears_the_reused_buffer_before_placing() {
365        let c = container(vec![row(Justify::Left, &[1, 2])]);
366        let mut out = Vec::new();
367        c.layout_into(box_of, &mut out);
368        c.layout_into(box_of, &mut out);
369        assert_eq!(out.len(), 2, "a reused buffer holds one solve, not two");
370        assert_eq!(out, c.layout(box_of));
371    }
372
373    #[test]
374    fn justify_names_parse_in_kebab_case() {
375        let j = |s: &str| serde_json::from_str::<Justify>(s).unwrap();
376        assert_eq!(j(r#""left""#), Justify::Left);
377        assert_eq!(j(r#""center""#), Justify::Center);
378        assert_eq!(j(r#""right""#), Justify::Right);
379        assert_eq!(j(r#""space-between""#), Justify::SpaceBetween);
380        assert_eq!(
381            serde_json::to_string(&Justify::SpaceBetween).unwrap(),
382            r#""space-between""#
383        );
384    }
385}