Skip to main content

presentar_widgets/
container.rs

1//! Container widget for layout grouping.
2
3use presentar_core::{
4    widget::LayoutResult, Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color,
5    Constraints, CornerRadius, Event, Rect, Size, TypeId, Widget,
6};
7use serde::{Deserialize, Serialize};
8use std::any::Any;
9use std::time::Duration;
10
11/// Container widget for grouping and styling children.
12#[derive(Serialize, Deserialize)]
13pub struct Container {
14    /// Background color
15    pub background: Option<Color>,
16    /// Corner radius for rounded corners
17    pub corner_radius: CornerRadius,
18    /// Padding (all sides)
19    pub padding: f32,
20    /// Minimum width constraint
21    pub min_width: Option<f32>,
22    /// Minimum height constraint
23    pub min_height: Option<f32>,
24    /// Maximum width constraint
25    pub max_width: Option<f32>,
26    /// Maximum height constraint
27    pub max_height: Option<f32>,
28    /// Children widgets
29    #[serde(skip)]
30    children: Vec<Box<dyn Widget>>,
31    /// Test ID for this widget
32    test_id_value: Option<String>,
33    /// Cached bounds after layout
34    #[serde(skip)]
35    bounds: Rect,
36}
37
38impl Default for Container {
39    fn default() -> Self {
40        Self {
41            background: None,
42            corner_radius: CornerRadius::ZERO,
43            padding: 0.0,
44            min_width: None,
45            min_height: None,
46            max_width: None,
47            max_height: None,
48            children: Vec::new(),
49            test_id_value: None,
50            bounds: Rect::default(),
51        }
52    }
53}
54
55impl Container {
56    /// Create a new empty container.
57    #[must_use]
58    pub fn new() -> Self {
59        Self::default()
60    }
61
62    /// Set the background color.
63    #[must_use]
64    pub const fn background(mut self, color: Color) -> Self {
65        self.background = Some(color);
66        self
67    }
68
69    /// Set the corner radius.
70    #[must_use]
71    pub const fn corner_radius(mut self, radius: CornerRadius) -> Self {
72        self.corner_radius = radius;
73        self
74    }
75
76    /// Set uniform padding on all sides.
77    #[must_use]
78    pub const fn padding(mut self, padding: f32) -> Self {
79        self.padding = padding;
80        self
81    }
82
83    /// Set minimum width.
84    #[must_use]
85    pub const fn min_width(mut self, width: f32) -> Self {
86        self.min_width = Some(width);
87        self
88    }
89
90    /// Set minimum height.
91    #[must_use]
92    pub const fn min_height(mut self, height: f32) -> Self {
93        self.min_height = Some(height);
94        self
95    }
96
97    /// Set maximum width.
98    #[must_use]
99    pub const fn max_width(mut self, width: f32) -> Self {
100        self.max_width = Some(width);
101        self
102    }
103
104    /// Set maximum height.
105    #[must_use]
106    pub const fn max_height(mut self, height: f32) -> Self {
107        self.max_height = Some(height);
108        self
109    }
110
111    /// Add a child widget.
112    pub fn child(mut self, widget: impl Widget + 'static) -> Self {
113        self.children.push(Box::new(widget));
114        self
115    }
116
117    /// Set the test ID.
118    #[must_use]
119    pub fn with_test_id(mut self, id: impl Into<String>) -> Self {
120        self.test_id_value = Some(id.into());
121        self
122    }
123}
124
125impl Widget for Container {
126    fn type_id(&self) -> TypeId {
127        TypeId::of::<Self>()
128    }
129
130    fn measure(&self, constraints: Constraints) -> Size {
131        let padding2 = self.padding * 2.0;
132
133        // Measure children
134        let child_constraints = Constraints::new(
135            0.0,
136            (constraints.max_width - padding2).max(0.0),
137            0.0,
138            (constraints.max_height - padding2).max(0.0),
139        );
140
141        let mut child_size = Size::ZERO;
142        for child in &self.children {
143            let size = child.measure(child_constraints);
144            child_size.width = child_size.width.max(size.width);
145            child_size.height = child_size.height.max(size.height);
146        }
147
148        // Add padding and apply constraints
149        let mut size = Size::new(child_size.width + padding2, child_size.height + padding2);
150
151        // Apply min/max constraints
152        if let Some(min_w) = self.min_width {
153            size.width = size.width.max(min_w);
154        }
155        if let Some(min_h) = self.min_height {
156            size.height = size.height.max(min_h);
157        }
158        if let Some(max_w) = self.max_width {
159            size.width = size.width.min(max_w);
160        }
161        if let Some(max_h) = self.max_height {
162            size.height = size.height.min(max_h);
163        }
164
165        constraints.constrain(size)
166    }
167
168    fn layout(&mut self, bounds: Rect) -> LayoutResult {
169        self.bounds = bounds;
170
171        // Layout children within padded bounds
172        let child_bounds = bounds.inset(self.padding);
173        for child in &mut self.children {
174            child.layout(child_bounds);
175        }
176
177        LayoutResult {
178            size: bounds.size(),
179        }
180    }
181
182    fn paint(&self, canvas: &mut dyn Canvas) {
183        // Draw background
184        if let Some(color) = self.background {
185            canvas.fill_rect(self.bounds, color);
186        }
187
188        // Paint children
189        for child in &self.children {
190            child.paint(canvas);
191        }
192    }
193
194    fn event(&mut self, event: &Event) -> Option<Box<dyn Any + Send>> {
195        // Propagate to children
196        for child in &mut self.children {
197            if let Some(msg) = child.event(event) {
198                return Some(msg);
199            }
200        }
201        None
202    }
203
204    fn children(&self) -> &[Box<dyn Widget>] {
205        &self.children
206    }
207
208    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
209        &mut self.children
210    }
211
212    fn test_id(&self) -> Option<&str> {
213        self.test_id_value.as_deref()
214    }
215}
216
217// PROBAR-SPEC-009: Brick Architecture - Tests define interface
218impl Brick for Container {
219    fn brick_name(&self) -> &'static str {
220        "Container"
221    }
222
223    fn assertions(&self) -> &[BrickAssertion] {
224        &[BrickAssertion::MaxLatencyMs(16)]
225    }
226
227    fn budget(&self) -> BrickBudget {
228        BrickBudget::uniform(16)
229    }
230
231    fn verify(&self) -> BrickVerification {
232        BrickVerification {
233            passed: self.assertions().to_vec(),
234            failed: vec![],
235            verification_time: Duration::from_micros(10),
236        }
237    }
238
239    fn to_html(&self) -> String {
240        let test_id = self.test_id_value.as_deref().unwrap_or("container");
241        format!(r#"<div class="brick-container" data-testid="{test_id}"></div>"#)
242    }
243
244    fn to_css(&self) -> String {
245        ".brick-container { display: block; }".into()
246    }
247
248    fn test_id(&self) -> Option<&str> {
249        self.test_id_value.as_deref()
250    }
251}
252
253#[cfg(test)]
254#[allow(clippy::unwrap_used, clippy::disallowed_methods)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn test_container_default() {
260        let c = Container::new();
261        assert!(c.background.is_none());
262        assert_eq!(c.padding, 0.0);
263        assert!(c.children.is_empty());
264    }
265
266    #[test]
267    fn test_container_builder() {
268        let c = Container::new()
269            .background(Color::WHITE)
270            .padding(10.0)
271            .min_width(100.0)
272            .with_test_id("my-container");
273
274        assert_eq!(c.background, Some(Color::WHITE));
275        assert_eq!(c.padding, 10.0);
276        assert_eq!(c.min_width, Some(100.0));
277        assert_eq!(Widget::test_id(&c), Some("my-container"));
278    }
279
280    #[test]
281    fn test_container_measure_empty() {
282        let c = Container::new().padding(10.0);
283        let size = c.measure(Constraints::loose(Size::new(100.0, 100.0)));
284        assert_eq!(size, Size::new(20.0, 20.0)); // padding * 2
285    }
286
287    #[test]
288    fn test_container_measure_with_min_size() {
289        let c = Container::new().min_width(50.0).min_height(50.0);
290        let size = c.measure(Constraints::loose(Size::new(100.0, 100.0)));
291        assert_eq!(size, Size::new(50.0, 50.0));
292    }
293
294    #[test]
295    fn test_container_measure_with_max_size() {
296        let c = Container::new()
297            .max_width(30.0)
298            .max_height(30.0)
299            .min_width(100.0);
300        let size = c.measure(Constraints::loose(Size::new(200.0, 200.0)));
301        assert_eq!(size.width, 30.0); // max wins over min
302    }
303
304    #[test]
305    fn test_container_corner_radius() {
306        let c = Container::new().corner_radius(CornerRadius::uniform(8.0));
307        assert_eq!(c.corner_radius, CornerRadius::uniform(8.0));
308    }
309
310    #[test]
311    fn test_container_type_id() {
312        let c = Container::new();
313        assert_eq!(Widget::type_id(&c), TypeId::of::<Container>());
314    }
315
316    #[test]
317    fn test_container_layout_sets_bounds() {
318        let mut c = Container::new().padding(10.0);
319        let result = c.layout(Rect::new(0.0, 0.0, 100.0, 80.0));
320        assert_eq!(result.size, Size::new(100.0, 80.0));
321        assert_eq!(c.bounds, Rect::new(0.0, 0.0, 100.0, 80.0));
322    }
323
324    #[test]
325    fn test_container_children_empty() {
326        let c = Container::new();
327        assert!(c.children().is_empty());
328    }
329
330    #[test]
331    fn test_container_event_no_children_returns_none() {
332        let mut c = Container::new();
333        c.layout(Rect::new(0.0, 0.0, 100.0, 100.0));
334        let result = c.event(&Event::MouseEnter);
335        assert!(result.is_none());
336    }
337
338    // Paint tests
339    use presentar_core::draw::DrawCommand;
340    use presentar_core::RecordingCanvas;
341
342    #[test]
343    fn test_container_paint_no_background() {
344        let mut c = Container::new();
345        c.layout(Rect::new(0.0, 0.0, 100.0, 100.0));
346        let mut canvas = RecordingCanvas::new();
347        c.paint(&mut canvas);
348        assert_eq!(canvas.command_count(), 0);
349    }
350
351    #[test]
352    fn test_container_paint_with_background() {
353        let mut c = Container::new().background(Color::RED);
354        c.layout(Rect::new(0.0, 0.0, 100.0, 50.0));
355        let mut canvas = RecordingCanvas::new();
356        c.paint(&mut canvas);
357        assert_eq!(canvas.command_count(), 1);
358        match &canvas.commands()[0] {
359            DrawCommand::Rect { bounds, style, .. } => {
360                assert_eq!(bounds.width, 100.0);
361                assert_eq!(bounds.height, 50.0);
362                assert_eq!(style.fill, Some(Color::RED));
363            }
364            _ => panic!("Expected Rect"),
365        }
366    }
367
368    // =========================================================================
369    // Builder Pattern Tests
370    // =========================================================================
371
372    #[test]
373    fn test_container_min_height_builder() {
374        let c = Container::new().min_height(75.0);
375        assert_eq!(c.min_height, Some(75.0));
376    }
377
378    #[test]
379    fn test_container_max_height_builder() {
380        let c = Container::new().max_height(150.0);
381        assert_eq!(c.max_height, Some(150.0));
382    }
383
384    #[test]
385    fn test_container_max_width_builder() {
386        let c = Container::new().max_width(200.0);
387        assert_eq!(c.max_width, Some(200.0));
388    }
389
390    #[test]
391    fn test_container_all_constraints() {
392        let c = Container::new()
393            .min_width(50.0)
394            .max_width(200.0)
395            .min_height(30.0)
396            .max_height(150.0);
397        assert_eq!(c.min_width, Some(50.0));
398        assert_eq!(c.max_width, Some(200.0));
399        assert_eq!(c.min_height, Some(30.0));
400        assert_eq!(c.max_height, Some(150.0));
401    }
402
403    #[test]
404    fn test_container_chained_all_builders() {
405        let c = Container::new()
406            .background(Color::BLUE)
407            .corner_radius(CornerRadius::uniform(10.0))
408            .padding(5.0)
409            .min_width(100.0)
410            .min_height(80.0)
411            .max_width(300.0)
412            .max_height(200.0)
413            .with_test_id("full-container");
414
415        assert_eq!(c.background, Some(Color::BLUE));
416        assert_eq!(c.corner_radius, CornerRadius::uniform(10.0));
417        assert_eq!(c.padding, 5.0);
418        assert_eq!(c.min_width, Some(100.0));
419        assert_eq!(c.min_height, Some(80.0));
420        assert_eq!(c.max_width, Some(300.0));
421        assert_eq!(c.max_height, Some(200.0));
422        assert_eq!(Widget::test_id(&c), Some("full-container"));
423    }
424
425    // =========================================================================
426    // Measure Tests
427    // =========================================================================
428
429    #[test]
430    fn test_container_measure_tight_constraints() {
431        let c = Container::new().padding(10.0);
432        let size = c.measure(Constraints::tight(Size::new(50.0, 50.0)));
433        // With tight constraints, padding is still applied but constrained
434        assert_eq!(size, Size::new(50.0, 50.0));
435    }
436
437    #[test]
438    fn test_container_measure_unbounded() {
439        let c = Container::new().min_width(100.0).min_height(50.0);
440        let size = c.measure(Constraints::unbounded());
441        assert_eq!(size, Size::new(100.0, 50.0));
442    }
443
444    #[test]
445    fn test_container_measure_min_overrides_content() {
446        let c = Container::new().min_width(200.0).min_height(200.0);
447        let size = c.measure(Constraints::loose(Size::new(500.0, 500.0)));
448        assert!(size.width >= 200.0);
449        assert!(size.height >= 200.0);
450    }
451
452    #[test]
453    fn test_container_measure_max_clamps() {
454        let c = Container::new().min_width(300.0).max_width(150.0); // max < min
455        let size = c.measure(Constraints::loose(Size::new(500.0, 500.0)));
456        // max wins after min is applied
457        assert_eq!(size.width, 150.0);
458    }
459
460    #[test]
461    fn test_container_measure_padding_only() {
462        let c = Container::new().padding(25.0);
463        let size = c.measure(Constraints::loose(Size::new(100.0, 100.0)));
464        assert_eq!(size, Size::new(50.0, 50.0)); // 25 * 2 on each axis
465    }
466
467    // =========================================================================
468    // Layout Tests
469    // =========================================================================
470
471    #[test]
472    fn test_container_layout_with_offset() {
473        let mut c = Container::new();
474        let result = c.layout(Rect::new(20.0, 30.0, 100.0, 80.0));
475        assert_eq!(result.size, Size::new(100.0, 80.0));
476        assert_eq!(c.bounds.x, 20.0);
477        assert_eq!(c.bounds.y, 30.0);
478    }
479
480    #[test]
481    fn test_container_layout_zero_size() {
482        let mut c = Container::new();
483        let result = c.layout(Rect::new(0.0, 0.0, 0.0, 0.0));
484        assert_eq!(result.size, Size::new(0.0, 0.0));
485    }
486
487    #[test]
488    fn test_container_layout_large_bounds() {
489        let mut c = Container::new();
490        let result = c.layout(Rect::new(0.0, 0.0, 10000.0, 10000.0));
491        assert_eq!(result.size, Size::new(10000.0, 10000.0));
492    }
493
494    // =========================================================================
495    // Children Tests
496    // =========================================================================
497
498    #[test]
499    fn test_container_children_mut_access() {
500        let mut c = Container::new();
501        assert!(c.children_mut().is_empty());
502    }
503
504    // =========================================================================
505    // Test ID Tests
506    // =========================================================================
507
508    #[test]
509    fn test_container_test_id_none_by_default() {
510        let c = Container::new();
511        assert!(Widget::test_id(&c).is_none());
512    }
513
514    #[test]
515    fn test_container_test_id_with_str() {
516        let c = Container::new().with_test_id("simple-id");
517        assert_eq!(Widget::test_id(&c), Some("simple-id"));
518    }
519
520    #[test]
521    fn test_container_test_id_with_string() {
522        let id = String::from("dynamic-id");
523        let c = Container::new().with_test_id(id);
524        assert_eq!(Widget::test_id(&c), Some("dynamic-id"));
525    }
526
527    // =========================================================================
528    // Corner Radius Tests
529    // =========================================================================
530
531    #[test]
532    fn test_container_corner_radius_zero() {
533        let c = Container::new().corner_radius(CornerRadius::ZERO);
534        assert_eq!(c.corner_radius, CornerRadius::ZERO);
535    }
536
537    #[test]
538    fn test_container_corner_radius_asymmetric() {
539        let radius = CornerRadius {
540            top_left: 5.0,
541            top_right: 10.0,
542            bottom_left: 15.0,
543            bottom_right: 20.0,
544        };
545        let c = Container::new().corner_radius(radius);
546        assert_eq!(c.corner_radius.top_left, 5.0);
547        assert_eq!(c.corner_radius.bottom_right, 20.0);
548    }
549
550    // =========================================================================
551    // Default Tests
552    // =========================================================================
553
554    #[test]
555    fn test_container_default_all_none() {
556        let c = Container::default();
557        assert!(c.background.is_none());
558        assert!(c.min_width.is_none());
559        assert!(c.min_height.is_none());
560        assert!(c.max_width.is_none());
561        assert!(c.max_height.is_none());
562        assert!(c.test_id_value.is_none());
563    }
564
565    #[test]
566    fn test_container_default_corner_radius_zero() {
567        let c = Container::default();
568        assert_eq!(c.corner_radius, CornerRadius::ZERO);
569    }
570
571    #[test]
572    fn test_container_default_bounds_zero() {
573        let c = Container::default();
574        assert_eq!(c.bounds, Rect::default());
575    }
576
577    // =========================================================================
578    // Serialization Tests
579    // =========================================================================
580
581    #[test]
582    fn test_container_serialize() {
583        let c = Container::new()
584            .background(Color::GREEN)
585            .padding(15.0)
586            .min_width(100.0);
587        let json = serde_json::to_string(&c).unwrap();
588        assert!(json.contains("background"));
589        assert!(json.contains("padding"));
590        assert!(json.contains("15"));
591    }
592
593    #[test]
594    fn test_container_deserialize() {
595        let json = r#"{"background":{"r":1.0,"g":0.0,"b":0.0,"a":1.0},"corner_radius":{"top_left":0.0,"top_right":0.0,"bottom_left":0.0,"bottom_right":0.0},"padding":10.0,"min_width":50.0,"min_height":null,"max_width":null,"max_height":null,"test_id_value":null}"#;
596        let c: Container = serde_json::from_str(json).unwrap();
597        assert_eq!(c.padding, 10.0);
598        assert_eq!(c.min_width, Some(50.0));
599    }
600
601    #[test]
602    fn test_container_roundtrip_serialization() {
603        let original = Container::new()
604            .background(Color::BLUE)
605            .padding(20.0)
606            .min_width(75.0)
607            .max_height(300.0);
608        let json = serde_json::to_string(&original).unwrap();
609        let deserialized: Container = serde_json::from_str(&json).unwrap();
610        assert_eq!(original.padding, deserialized.padding);
611        assert_eq!(original.min_width, deserialized.min_width);
612        assert_eq!(original.max_height, deserialized.max_height);
613        assert_eq!(original.background, deserialized.background);
614    }
615
616    // =========================================================================
617    // Paint Edge Cases
618    // =========================================================================
619
620    #[test]
621    fn test_container_paint_transparent_background() {
622        let mut c = Container::new().background(Color::TRANSPARENT);
623        c.layout(Rect::new(0.0, 0.0, 100.0, 100.0));
624        let mut canvas = RecordingCanvas::new();
625        c.paint(&mut canvas);
626        // Should still paint even with transparent
627        assert_eq!(canvas.command_count(), 1);
628    }
629
630    #[test]
631    fn test_container_paint_after_layout() {
632        let mut c = Container::new().background(Color::WHITE);
633        // Layout at specific position
634        c.layout(Rect::new(50.0, 50.0, 80.0, 60.0));
635        let mut canvas = RecordingCanvas::new();
636        c.paint(&mut canvas);
637        match &canvas.commands()[0] {
638            DrawCommand::Rect { bounds, .. } => {
639                assert_eq!(bounds.x, 50.0);
640                assert_eq!(bounds.y, 50.0);
641                assert_eq!(bounds.width, 80.0);
642                assert_eq!(bounds.height, 60.0);
643            }
644            _ => panic!("Expected Rect"),
645        }
646    }
647
648    // =========================================================================
649    // Edge Cases
650    // =========================================================================
651
652    #[test]
653    fn test_container_zero_padding_measure() {
654        let c = Container::new().padding(0.0);
655        let size = c.measure(Constraints::loose(Size::new(100.0, 100.0)));
656        assert_eq!(size, Size::new(0.0, 0.0)); // No content, no padding
657    }
658
659    #[test]
660    fn test_container_negative_constraints_handled() {
661        // Constraints with negative max should clamp to 0
662        let c = Container::new().padding(10.0);
663        let size = c.measure(Constraints::new(0.0, 5.0, 0.0, 5.0));
664        // Padding is 20 total but max is 5, so constrained
665        assert_eq!(size, Size::new(5.0, 5.0));
666    }
667}