concinnity_core/components/
layout_container.rs1use crate::ecs::asset_id::AssetId;
4use alloc::vec::Vec;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
9#[serde(rename_all = "kebab-case")]
10pub enum Justify {
11 #[default]
13 Left,
14 Center,
16 Right,
18 SpaceBetween,
21}
22
23#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
25#[serde(default)]
26pub struct LayoutRow {
27 pub cols: Vec<AssetId>,
30 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
68#[serde(default)]
69pub struct LayoutContainer {
70 pub x: f32,
72 pub y: f32,
74 pub col_gap: f32,
77 pub row_gap: f32,
79 pub rows: Vec<LayoutRow>,
81 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#[derive(Debug, Clone, Copy, PartialEq)]
106pub struct LabelBox {
107 pub w: f32,
109 pub h: f32,
111 pub pad: f32,
113 pub top_inset: f32,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq)]
119pub struct LabelPlacement {
120 pub id: AssetId,
122 pub x: f32,
124 pub y: f32,
126}
127
128impl LayoutContainer {
129 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 pub fn layout_into(
145 &self,
146 size_of: impl Fn(AssetId) -> Option<LabelBox>,
147 out: &mut Vec<LabelPlacement>,
148 ) {
149 out.clear();
150 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 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 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 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 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 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 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 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 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 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 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}