Skip to main content

dotzuki_renderer/layout_engine/elements/
group.rs

1//! Layout container — groups children with coordinate translation.
2//!
3//! A [`Group`] is a rectangular container that manages child elements,
4//! translating their coordinates from group-relative to screen-absolute
5//! space. It supports three layout modes:
6//!
7//! * **Absolute** — children use their own explicit coordinates within
8//!   the group.
9//! * **Horizontal** — children are laid out left-to-right with a
10//!   configurable gap.
11//! * **Vertical** — children are laid out top-to-bottom with a
12//!   configurable gap.
13//!
14//! Groups can optionally apply a [`Border`] around their bounds and clip
15//! child rendering to the group rect.
16
17use crate::layout_engine::elements::border::Border;
18use crate::layout_engine::types::{
19    DataContext, Direction, LayoutConfig, LayoutElement, RenderContext, RenderError,
20};
21use dotzuki_engine::render::{Painter, TilePos, TileRect};
22
23// ── ChildRect ────────────────────────────────────────────────────────────
24
25/// Describes a child element's rectangle resolved for layout.
26///
27/// This is used when the group computes a child's actual position on
28/// screen after applying the layout rule (absolute / horizontal / vertical).
29#[derive(Debug, Clone, Copy)]
30pub struct ChildRect {
31    /// The child's computed absolute tile rectangle on screen.
32    pub rect: TileRect,
33    /// The child's original z-index (passed through from [`LayoutElement`]).
34    pub z_index: i32,
35    /// The child's visibility flag.
36    pub visible: bool,
37}
38
39// ── GroupLayout ──────────────────────────────────────────────────────────
40
41/// Layout mode for a group's children.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum GroupLayout {
44    /// Children are placed at their own explicit `rect.tx` / `rect.ty`
45    /// positions relative to the group's top-left corner.
46    Absolute,
47    /// Children are laid out left-to-right. Each child after the first
48    /// is offset by the cumulative width of preceding children plus `gap`.
49    Horizontal { gap: u32 },
50    /// Children are laid out top-to-bottom. Each child after the first
51    /// is offset by the cumulative height of preceding children plus `gap`.
52    Vertical { gap: u32 },
53}
54
55impl GroupLayout {
56    /// Derive a [`GroupLayout`] from a [`LayoutConfig`].
57    pub fn from_config(config: &LayoutConfig) -> Self {
58        match config.direction {
59            Some(Direction::Horizontal) => GroupLayout::Horizontal { gap: config.gap },
60            Some(Direction::Vertical) => GroupLayout::Vertical { gap: config.gap },
61            None => GroupLayout::Absolute,
62        }
63    }
64
65    /// Whether children are automatically positioned (horizontal or vertical).
66    #[inline]
67    pub fn is_auto(&self) -> bool {
68        !matches!(self, GroupLayout::Absolute)
69    }
70}
71
72impl Default for GroupLayout {
73    fn default() -> Self {
74        GroupLayout::Absolute
75    }
76}
77
78// ── Group ────────────────────────────────────────────────────────────────
79
80/// A layout container that manages children with coordinate translation.
81///
82/// # Examples
83///
84/// ```
85/// use dotzuki_renderer::layout_engine::elements::group::{Group, GroupLayout};
86/// use dotzuki_engine::render::TileRect;
87///
88/// let group = Group::new(TileRect::new(0, 0, 10, 10))
89///     .with_layout(GroupLayout::Vertical { gap: 1 })
90///     .with_clip(true);
91/// ```
92#[derive(Debug, Clone)]
93pub struct Group {
94    /// The group's bounding rectangle in screen-absolute tile coordinates.
95    pub rect: TileRect,
96    /// How children are positioned within the group.
97    pub layout: GroupLayout,
98    /// Whether child rendering is clipped to `rect`.
99    pub clip: bool,
100    /// Optional border rendered around the group.
101    pub border: Option<Border>,
102}
103
104impl Group {
105    /// Create a new group with absolute layout, no clip, and no border.
106    #[inline]
107    pub fn new(rect: TileRect) -> Self {
108        Self {
109            rect,
110            layout: GroupLayout::Absolute,
111            clip: false,
112            border: None,
113        }
114    }
115
116    /// Set the layout mode.
117    #[inline]
118    pub fn with_layout(mut self, layout: GroupLayout) -> Self {
119        self.layout = layout;
120        self
121    }
122
123    /// Enable or disable clipping.
124    #[inline]
125    pub fn with_clip(mut self, clip: bool) -> Self {
126        self.clip = clip;
127        self
128    }
129
130    /// Attach a border to the group.
131    #[inline]
132    pub fn with_border(mut self, border: Border) -> Self {
133        self.border = Some(border);
134        self
135    }
136
137    // ── Coordinate translation ────────────────────────────────────────
138
139    /// Translate a group-relative tile position to screen-absolute.
140    ///
141    /// Adds the group's top-left offset to the given coordinates.
142    /// This is the core coordinate translation used when rendering
143    /// children that specify their positions relative to the group.
144    #[inline]
145    pub fn to_absolute(&self, tx: u32, ty: u32) -> TilePos {
146        TilePos::new(self.rect.tx + tx, self.rect.ty + ty)
147    }
148
149    /// Translate a group-relative tile rectangle to screen-absolute.
150    #[inline]
151    pub fn rect_to_absolute(&self, relative: TileRect) -> TileRect {
152        TileRect::new(
153            self.rect.tx + relative.tx,
154            self.rect.ty + relative.ty,
155            relative.tw,
156            relative.th,
157        )
158    }
159
160    /// Compute the screen-absolute rectangle for a child element.
161    ///
162    /// For [`GroupLayout::Absolute`], the child's own `rect.tx`/`rect.ty`
163    /// are treated as offsets from the group origin.
164    ///
165    /// For automatic layouts ([`GroupLayout::Horizontal`] /
166    /// [`GroupLayout::Vertical`]), the child's explicit `tx`/`ty` are
167    /// overridden by the computed layout position; the child still
168    /// controls its own `tw`/`th`.
169    pub fn child_rect(&self, child: &LayoutElement, _index: usize, ctx: &DataContext) -> TileRect {
170        let child_tw = child.rect.tw.unwrap_or(0);
171        let child_th = child.rect.th.unwrap_or(0);
172
173        let (rel_tx, rel_ty) = match self.layout {
174            GroupLayout::Absolute => (child.rect.tx.resolve(ctx), child.rect.ty.resolve(ctx)),
175            GroupLayout::Horizontal { .. } => {
176                (0, 0)
177            }
178            GroupLayout::Vertical { .. } => {
179                (0, 0)
180            }
181        };
182
183        TileRect::new(
184            self.rect.tx + rel_tx,
185            self.rect.ty + rel_ty,
186            child_tw,
187            child_th,
188        )
189    }
190
191    // ── Layout computation ────────────────────────────────────────────
192
193    /// Compute the layout offset for the child at `index` from the actual
194    /// child list.
195    ///
196    /// Returns the (x, y) offset in tiles from the group origin that this
197    /// child should be placed at, based on the layout mode and the actual
198    /// widths/heights of previous children.
199    ///
200    /// For [`GroupLayout::Absolute`], always returns `(0, 0)` — the child's
201    /// own `rect.tx`/`rect.ty` are used directly.
202    pub fn layout_offset(&self, index: usize, children: &[LayoutElement]) -> (u32, u32) {
203        match self.layout {
204            GroupLayout::Absolute => (0, 0),
205            GroupLayout::Horizontal { gap } => {
206                let mut x = 0u32;
207                for child in children.iter().take(index) {
208                    x += child.rect.tw.unwrap_or(0) + gap;
209                }
210                (x, 0)
211            }
212            GroupLayout::Vertical { gap } => {
213                let mut y = 0u32;
214                for child in children.iter().take(index) {
215                    y += child.rect.th.unwrap_or(0) + gap;
216                }
217                (0, y)
218            }
219        }
220    }
221
222    /// Resolve all children to their screen-absolute rectangles.
223    ///
224    /// Returns a vector of [`ChildRect`] in the same order as `children`,
225    /// each with a computed absolute position based on the layout mode.
226    pub fn resolve_children(&self, children: &[LayoutElement], ctx: &DataContext) -> Vec<ChildRect> {
227        children
228            .iter()
229            .enumerate()
230            .map(|(i, child)| {
231                let (layout_dx, layout_dy) = self.layout_offset(i, children);
232                let child_tx = match self.layout {
233                    GroupLayout::Absolute => child.rect.tx.resolve(ctx),
234                    _ => layout_dx,
235                };
236                let child_ty = match self.layout {
237                    GroupLayout::Absolute => child.rect.ty.resolve(ctx),
238                    _ => layout_dy,
239                };
240
241                ChildRect {
242                    rect: TileRect::new(
243                        self.rect.tx + child_tx,
244                        self.rect.ty + child_ty,
245                        child.rect.tw.unwrap_or(0),
246                        child.rect.th.unwrap_or(0),
247                    ),
248                    z_index: child.z_index,
249                    visible: child.visible.eval(ctx),
250                }
251            })
252            .collect()
253    }
254
255    // ── Rendering ──────────────────────────────────────────────────────
256
257    /// Render the group's border (if any) into the given [`Painter`].
258    ///
259    /// This does **not** render children — it only draws the group's
260    /// own border or background fill. Child rendering is handled by
261    /// the layout engine's element dispatch.
262    pub fn render_border(&self, painter: &mut dyn Painter) {
263        if let Some(ref border) = self.border {
264            border.render(painter);
265        }
266    }
267
268    /// Render the group including border and children via the layout engine.
269    ///
270    /// When `clip` is enabled, pixels outside `rect` should be masked.
271    /// Current implementation renders all visible children; clipping is
272    /// a future enhancement for when a clip-stack is added to the painter.
273    pub fn render(
274        &self,
275        children: &[LayoutElement],
276        ctx: &DataContext,
277        render_ctx: &RenderContext,
278        painter: &mut dyn Painter,
279        registry: &crate::layout_engine::registry::ElementRegistry,
280    ) -> Result<(), RenderError> {
281        // 1. Render the border
282        self.render_border(painter);
283
284        // 2. Resolve child positions
285        let resolved = self.resolve_children(children, ctx);
286
287        // 3. Sort by z_index (stable sort preserves original order for equal z)
288        let mut sorted: Vec<(usize, &ChildRect)> =
289            resolved.iter().enumerate().collect();
290        sorted.sort_by_key(|(_, cr)| cr.z_index);
291
292        // 4. Render children
293        for (child_idx, child_rect) in sorted {
294            if !child_rect.visible {
295                continue;
296            }
297            let child_elem = &children[child_idx];
298
299            // Skip children outside group rect when clipping is enabled
300            if self.clip && !self.overlaps(child_rect.rect) {
301                continue;
302            }
303
304            // Look up the element type in the registry
305            if let Some(custom_elem) = registry.get(&child_elem.element_type) {
306                custom_elem.render(child_elem, ctx, render_ctx, painter)?;
307            }
308        }
309
310        Ok(())
311    }
312
313    /// Check whether a rectangle overlaps with the group's rect.
314    fn overlaps(&self, r: TileRect) -> bool {
315        let gx2 = self.rect.tx + self.rect.tw;
316        let gy2 = self.rect.ty + self.rect.th;
317        let rx2 = r.tx + r.tw;
318        let ry2 = r.ty + r.th;
319
320        r.tx < gx2 && self.rect.tx < rx2 && r.ty < gy2 && self.rect.ty < ry2
321    }
322}
323
324// ── Tests ────────────────────────────────────────────────────────────────
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use crate::layout_engine::types::{Coord, ElementRect, EdgeInsets};
330    use dotzuki_engine::render::{Rgba, TileRect};
331
332    // Helper: create a minimal LayoutElement for testing
333    fn make_element(tx: u32, ty: u32, tw: u32, th: u32) -> LayoutElement {
334        LayoutElement {
335            id: String::new(),
336            element_type: "text".to_string(),
337            rect: ElementRect {
338                tx: Coord::Literal(tx),
339                ty: Coord::Literal(ty),
340                tw: Some(tw),
341                th: Some(th),
342            },
343            visible: crate::layout_engine::types::Visibility::Static(true),
344            z_index: 0,
345            params: crate::layout_engine::types::ElementParams::Custom(
346                serde_json::Value::Null,
347            ),
348        }
349    }
350
351    // ── GroupLayout tests ──────────────────────────────────────────────
352
353    #[test]
354    fn layout_from_config_horizontal() {
355        let config = LayoutConfig {
356            direction: Some(Direction::Horizontal),
357            gap: 2,
358            padding: EdgeInsets::default(),
359        };
360        let layout = GroupLayout::from_config(&config);
361        assert_eq!(layout, GroupLayout::Horizontal { gap: 2 });
362    }
363
364    #[test]
365    fn layout_from_config_vertical() {
366        let config = LayoutConfig {
367            direction: Some(Direction::Vertical),
368            gap: 1,
369            padding: EdgeInsets::default(),
370        };
371        let layout = GroupLayout::from_config(&config);
372        assert_eq!(layout, GroupLayout::Vertical { gap: 1 });
373    }
374
375    #[test]
376    fn layout_from_config_absolute_when_no_direction() {
377        let config = LayoutConfig {
378            direction: None,
379            gap: 5,
380            padding: EdgeInsets::default(),
381        };
382        let layout = GroupLayout::from_config(&config);
383        assert_eq!(layout, GroupLayout::Absolute);
384    }
385
386    #[test]
387    fn is_auto_returns_false_for_absolute() {
388        assert!(!GroupLayout::Absolute.is_auto());
389    }
390
391    #[test]
392    fn is_auto_returns_true_for_horizontal_and_vertical() {
393        assert!(GroupLayout::Horizontal { gap: 0 }.is_auto());
394        assert!(GroupLayout::Vertical { gap: 0 }.is_auto());
395    }
396
397    // ── Group construction tests ───────────────────────────────────────
398
399    #[test]
400    fn group_defaults() {
401        let rect = TileRect::new(5, 5, 10, 8);
402        let g = Group::new(rect);
403        assert_eq!(g.rect, rect);
404        assert_eq!(g.layout, GroupLayout::Absolute);
405        assert!(!g.clip);
406        assert!(g.border.is_none());
407    }
408
409    #[test]
410    fn group_builder_pattern() {
411        let rect = TileRect::new(0, 0, 20, 18);
412        let border = Border::new(rect, Rgba::INK_BLACK);
413        let g = Group::new(rect)
414            .with_layout(GroupLayout::Vertical { gap: 2 })
415            .with_clip(true)
416            .with_border(border);
417
418        assert_eq!(g.layout, GroupLayout::Vertical { gap: 2 });
419        assert!(g.clip);
420        assert!(g.border.is_some());
421    }
422
423    // ── Coordinate translation tests ──────────────────────────────────
424
425    #[test]
426    fn to_absolute_adds_group_offset() {
427        let g = Group::new(TileRect::new(3, 7, 10, 10));
428        assert_eq!(g.to_absolute(0, 0), TilePos::new(3, 7));
429        assert_eq!(g.to_absolute(2, 3), TilePos::new(5, 10));
430        assert_eq!(g.to_absolute(9, 9), TilePos::new(12, 16));
431    }
432
433    #[test]
434    fn rect_to_absolute_preserves_size() {
435        let g = Group::new(TileRect::new(2, 3, 10, 10));
436        let rel = TileRect::new(1, 1, 4, 3);
437        let abs = g.rect_to_absolute(rel);
438        assert_eq!(abs.tx, 3); // 2 + 1
439        assert_eq!(abs.ty, 4); // 3 + 1
440        assert_eq!(abs.tw, 4);
441        assert_eq!(abs.th, 3);
442    }
443
444    // ── Layout offset tests ───────────────────────────────────────────
445
446    #[test]
447    fn absolute_layout_offset_is_zero() {
448        let g = Group::new(TileRect::new(0, 0, 20, 18));
449        let children = vec![make_element(0, 0, 5, 2), make_element(0, 0, 3, 2)];
450        assert_eq!(g.layout_offset(0, &children), (0, 0));
451        assert_eq!(g.layout_offset(1, &children), (0, 0));
452    }
453
454    #[test]
455    fn horizontal_layout_offset_accumulates_widths() {
456        let g = Group::new(TileRect::new(0, 0, 20, 18))
457            .with_layout(GroupLayout::Horizontal { gap: 1 });
458        let children = vec![
459            make_element(0, 0, 5, 2), // index 0: x=0
460            make_element(0, 0, 3, 2), // index 1: x=5+1=6
461            make_element(0, 0, 4, 2), // index 2: x=6+3+1=10
462        ];
463
464        assert_eq!(g.layout_offset(0, &children), (0, 0));
465        assert_eq!(g.layout_offset(1, &children), (6, 0));
466        assert_eq!(g.layout_offset(2, &children), (10, 0));
467    }
468
469    #[test]
470    fn vertical_layout_offset_accumulates_heights() {
471        let g = Group::new(TileRect::new(0, 0, 20, 18))
472            .with_layout(GroupLayout::Vertical { gap: 2 });
473        let children = vec![
474            make_element(0, 0, 10, 3), // index 0: y=0
475            make_element(0, 0, 10, 5), // index 1: y=3+2=5
476            make_element(0, 0, 10, 2), // index 2: y=5+5+2=12
477        ];
478
479        assert_eq!(g.layout_offset(0, &children), (0, 0));
480        assert_eq!(g.layout_offset(1, &children), (0, 5));
481        assert_eq!(g.layout_offset(2, &children), (0, 12));
482    }
483
484    #[test]
485    fn horizontal_layout_no_gap() {
486        let g = Group::new(TileRect::new(0, 0, 20, 18))
487            .with_layout(GroupLayout::Horizontal { gap: 0 });
488        let children = vec![make_element(0, 0, 3, 1), make_element(0, 0, 7, 1)];
489        assert_eq!(g.layout_offset(1, &children), (3, 0));
490    }
491
492    // ── Resolve children tests ────────────────────────────────────────
493
494    #[test]
495    fn resolve_absolute_children_preserves_positions() {
496        let g = Group::new(TileRect::new(2, 3, 10, 10));
497        let children = vec![
498            make_element(0, 0, 4, 2),
499            make_element(1, 1, 6, 3),
500        ];
501        let ctx = DataContext::new();
502
503        let resolved = g.resolve_children(&children, &ctx);
504        assert_eq!(resolved.len(), 2);
505
506        // child 0 at (2+0, 3+0)
507        assert_eq!(resolved[0].rect, TileRect::new(2, 3, 4, 2));
508        // child 1 at (2+1, 3+1)
509        assert_eq!(resolved[1].rect, TileRect::new(3, 4, 6, 3));
510    }
511
512    #[test]
513    fn resolve_horizontal_children() {
514        let g = Group::new(TileRect::new(1, 1, 20, 10))
515            .with_layout(GroupLayout::Horizontal { gap: 1 });
516        let children = vec![
517            make_element(0, 0, 5, 3),
518            make_element(0, 0, 4, 3),
519            make_element(0, 0, 6, 3),
520        ];
521        let ctx = DataContext::new();
522
523        let resolved = g.resolve_children(&children, &ctx);
524
525        // child 0 at (1+0, 1+0) = (1, 1)
526        assert_eq!(resolved[0].rect, TileRect::new(1, 1, 5, 3));
527        // child 1 at (1+6, 1+0) = (7, 1)
528        assert_eq!(resolved[1].rect, TileRect::new(7, 1, 4, 3));
529        // child 2 at (1+11, 1+0) = (12, 1)
530        assert_eq!(resolved[2].rect, TileRect::new(12, 1, 6, 3));
531    }
532
533    #[test]
534    fn resolve_vertical_children() {
535        let g = Group::new(TileRect::new(0, 2, 10, 15))
536            .with_layout(GroupLayout::Vertical { gap: 0 });
537        let children = vec![
538            make_element(0, 0, 10, 3),
539            make_element(0, 0, 10, 4),
540        ];
541        let ctx = DataContext::new();
542
543        let resolved = g.resolve_children(&children, &ctx);
544        // child 0 at (0, 2+0) = (0, 2)
545        assert_eq!(resolved[0].rect, TileRect::new(0, 2, 10, 3));
546        // child 1 at (0, 2+3) = (0, 5)
547        assert_eq!(resolved[1].rect, TileRect::new(0, 5, 10, 4));
548    }
549
550    #[test]
551    fn resolve_passes_z_index_and_visibility() {
552        let g = Group::new(TileRect::new(0, 0, 10, 10));
553        let mut c0 = make_element(0, 0, 3, 3);
554        c0.z_index = 5;
555        c0.visible = crate::layout_engine::types::Visibility::Static(false);
556        let c1 = make_element(1, 1, 3, 3);
557        let ctx = DataContext::new();
558
559        let resolved = g.resolve_children(&[c0, c1], &ctx);
560        assert_eq!(resolved[0].z_index, 5);
561        assert!(!resolved[0].visible);
562        assert_eq!(resolved[1].z_index, 0);
563        assert!(resolved[1].visible);
564    }
565
566    // ── Overlap test ─────────────────────────────────────────────────
567
568    #[test]
569    fn overlaps_detects_intersection() {
570        let g = Group::new(TileRect::new(0, 0, 10, 10));
571
572        // Fully inside
573        assert!(g.overlaps(TileRect::new(2, 2, 4, 4)));
574
575        // Partially overlapping
576        assert!(g.overlaps(TileRect::new(8, 8, 6, 6)));
577
578        // Left edge
579        assert!(g.overlaps(TileRect::new(0, 1, 1, 1)));
580
581        // Top edge
582        assert!(g.overlaps(TileRect::new(1, 0, 1, 1)));
583    }
584
585    #[test]
586    fn overlaps_detects_non_intersection() {
587        let g = Group::new(TileRect::new(0, 0, 10, 10));
588
589        // Completely to the right
590        assert!(!g.overlaps(TileRect::new(10, 0, 1, 1)));
591
592        // Completely below
593        assert!(!g.overlaps(TileRect::new(0, 10, 1, 1)));
594
595        // Completely to the left (wraps would be negative, so use 5,5 with tw=5 → 5+5=10)
596        // Negative tx is not possible with u32, test far-away positions
597        assert!(!g.overlaps(TileRect::new(20, 20, 1, 1)));
598    }
599
600    // ── Same position for multiple children ───────────────────────────
601
602    #[test]
603    fn resolve_single_child_at_origin() {
604        let g = Group::new(TileRect::new(0, 0, 20, 18));
605        let children = vec![make_element(0, 0, 20, 18)];
606        let ctx = DataContext::new();
607        let resolved = g.resolve_children(&children, &ctx);
608        assert_eq!(resolved.len(), 1);
609        assert_eq!(resolved[0].rect, TileRect::new(0, 0, 20, 18));
610    }
611
612    #[test]
613    fn resolve_empty_children() {
614        let g = Group::new(TileRect::new(0, 0, 10, 10));
615        let resolved = g.resolve_children(&[], &DataContext::new());
616        assert!(resolved.is_empty());
617    }
618
619    // ── child_rect method tests ────────────────────────────────────────
620
621    #[test]
622    fn child_rect_absolute_mode() {
623        let g = Group::new(TileRect::new(5, 5, 20, 18));
624        let child = make_element(2, 3, 8, 4);
625        let ctx = DataContext::new();
626
627        let cr = g.child_rect(&child, 0, &ctx);
628        assert_eq!(cr.tx, 7); // 5 + 2
629        assert_eq!(cr.ty, 8); // 5 + 3
630        assert_eq!(cr.tw, 8);
631        assert_eq!(cr.th, 4);
632    }
633
634    #[test]
635    fn child_rect_horizontal_mode() {
636        let g = Group::new(TileRect::new(1, 1, 20, 10))
637            .with_layout(GroupLayout::Horizontal { gap: 1 });
638        let child = make_element(99, 99, 4, 3); // explicit tx/ty ignored
639        let ctx = DataContext::new();
640        let cr = g.child_rect(&child, 0, &ctx);
641        assert_eq!(cr.tx, 1); // 1 + 0 (first child)
642        assert_eq!(cr.ty, 1); // 1 + 0
643        assert_eq!(cr.tw, 4);
644        assert_eq!(cr.th, 3);
645    }
646
647    #[test]
648    fn child_rect_vertical_mode() {
649        let g = Group::new(TileRect::new(2, 2, 10, 20))
650            .with_layout(GroupLayout::Vertical { gap: 2 });
651        let child = make_element(42, 42, 6, 5);
652        let ctx = DataContext::new();
653        let cr = g.child_rect(&child, 0, &ctx);
654        assert_eq!(cr.tx, 2); // 2 + 0
655        assert_eq!(cr.ty, 2); // 2 + 0
656        assert_eq!(cr.tw, 6);
657        assert_eq!(cr.th, 5);
658    }
659}