Skip to main content

oxidize_pdf/dashboard/
component.rs

1//! Dashboard Component System
2//!
3//! This module defines the trait and types for dashboard components. All dashboard
4//! elements (KPI cards, charts, tables, etc.) implement the DashboardComponent trait
5//! to ensure consistent rendering and layout behavior.
6
7use super::theme::DashboardTheme;
8use crate::error::PdfError;
9use crate::graphics::Point;
10use crate::page::Page;
11
12/// Trait that all dashboard components must implement
13pub trait DashboardComponent: std::fmt::Debug + DashboardComponentClone {
14    /// Render the component to a PDF page at the specified position
15    fn render(
16        &self,
17        page: &mut Page,
18        position: ComponentPosition,
19        theme: &DashboardTheme,
20    ) -> Result<(), PdfError>;
21
22    /// Get the column span for this component (1-12)
23    fn get_span(&self) -> ComponentSpan;
24
25    /// Set the column span for this component
26    fn set_span(&mut self, span: ComponentSpan);
27
28    /// Get the preferred height for this component in points
29    fn preferred_height(&self, available_width: f64) -> f64;
30
31    /// Get the minimum width required for this component
32    fn minimum_width(&self) -> f64 {
33        50.0 // Default minimum width
34    }
35
36    /// Estimate rendering time in milliseconds
37    fn estimated_render_time_ms(&self) -> u32 {
38        10 // Default estimate
39    }
40
41    /// Estimate memory usage in MB
42    fn estimated_memory_mb(&self) -> f64 {
43        0.1 // Default estimate
44    }
45
46    /// Get complexity score (0-100)
47    fn complexity_score(&self) -> u8 {
48        25 // Default complexity
49    }
50
51    /// Get component type name for debugging
52    fn component_type(&self) -> &'static str;
53
54    /// Validate component configuration
55    fn validate(&self) -> Result<(), PdfError> {
56        // Default validation - components can override
57        if self.get_span().columns < 1 || self.get_span().columns > 12 {
58            return Err(PdfError::InvalidOperation(format!(
59                "Invalid span: {}. Must be 1-12",
60                self.get_span().columns
61            )));
62        }
63        Ok(())
64    }
65}
66
67/// Helper trait for cloning dashboard components
68pub trait DashboardComponentClone {
69    fn clone_box(&self) -> Box<dyn DashboardComponent>;
70}
71
72impl<T> DashboardComponentClone for T
73where
74    T: 'static + DashboardComponent + Clone,
75{
76    fn clone_box(&self) -> Box<dyn DashboardComponent> {
77        Box::new(self.clone())
78    }
79}
80
81impl Clone for Box<dyn DashboardComponent> {
82    fn clone(&self) -> Box<dyn DashboardComponent> {
83        self.clone_box()
84    }
85}
86
87/// Position and dimensions for a component within the dashboard grid
88#[derive(Debug, Clone, Copy)]
89pub struct ComponentPosition {
90    /// X coordinate in points
91    pub x: f64,
92    /// Y coordinate in points
93    pub y: f64,
94    /// Width in points
95    pub width: f64,
96    /// Height in points
97    pub height: f64,
98}
99
100impl ComponentPosition {
101    /// Create a new component position
102    pub fn new(x: f64, y: f64, width: f64, height: f64) -> Self {
103        Self {
104            x,
105            y,
106            width,
107            height,
108        }
109    }
110
111    /// Get the center point of this position
112    pub fn center(&self) -> Point {
113        Point::new(self.x + self.width / 2.0, self.y + self.height / 2.0)
114    }
115
116    /// Get the top-left corner
117    pub fn top_left(&self) -> Point {
118        Point::new(self.x, self.y + self.height)
119    }
120
121    /// Get the bottom-right corner
122    pub fn bottom_right(&self) -> Point {
123        Point::new(self.x + self.width, self.y)
124    }
125
126    /// Create a position with padding applied
127    pub fn with_padding(&self, padding: f64) -> Self {
128        Self {
129            x: self.x + padding,
130            y: self.y + padding,
131            width: self.width - 2.0 * padding,
132            height: self.height - 2.0 * padding,
133        }
134    }
135
136    /// Check if this position contains a point
137    pub fn contains(&self, point: Point) -> bool {
138        point.x >= self.x
139            && point.x <= self.x + self.width
140            && point.y >= self.y
141            && point.y <= self.y + self.height
142    }
143
144    /// Get aspect ratio (width/height)
145    pub fn aspect_ratio(&self) -> f64 {
146        if self.height > 0.0 {
147            self.width / self.height
148        } else {
149            1.0
150        }
151    }
152}
153
154/// Column span configuration for grid layout
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub struct ComponentSpan {
157    /// Number of columns to span (1-12)
158    pub columns: u8,
159    /// Optional row span for multi-row components
160    pub rows: Option<u8>,
161}
162
163impl ComponentSpan {
164    /// Create a new component span
165    pub fn new(columns: u8) -> Self {
166        Self {
167            columns: columns.clamp(1, 12),
168            rows: None,
169        }
170    }
171
172    /// Create a span with both column and row specification
173    pub fn with_rows(columns: u8, rows: u8) -> Self {
174        Self {
175            columns: columns.clamp(1, 12),
176            rows: Some(rows.max(1)),
177        }
178    }
179
180    /// Get column span as a fraction (0.0-1.0)
181    pub fn as_fraction(&self) -> f64 {
182        self.columns as f64 / 12.0
183    }
184
185    /// Check if this is a full-width component
186    pub fn is_full_width(&self) -> bool {
187        self.columns == 12
188    }
189
190    /// Check if this is a half-width component
191    pub fn is_half_width(&self) -> bool {
192        self.columns == 6
193    }
194
195    /// Check if this is a quarter-width component
196    pub fn is_quarter_width(&self) -> bool {
197        self.columns == 3
198    }
199}
200
201impl From<u8> for ComponentSpan {
202    fn from(columns: u8) -> Self {
203        Self::new(columns)
204    }
205}
206
207/// Component alignment options within its allocated space
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub enum ComponentAlignment {
210    /// Align to the left/top of the space
211    Start,
212    /// Center within the space
213    Center,
214    /// Align to the right/bottom of the space
215    End,
216    /// Stretch to fill the entire space
217    Stretch,
218}
219
220impl Default for ComponentAlignment {
221    fn default() -> Self {
222        Self::Stretch
223    }
224}
225
226/// Component margin configuration
227#[derive(Debug, Clone, Copy)]
228pub struct ComponentMargin {
229    /// Top margin in points
230    pub top: f64,
231    /// Right margin in points
232    pub right: f64,
233    /// Bottom margin in points
234    pub bottom: f64,
235    /// Left margin in points
236    pub left: f64,
237}
238
239impl ComponentMargin {
240    /// Create uniform margin
241    pub fn uniform(margin: f64) -> Self {
242        Self {
243            top: margin,
244            right: margin,
245            bottom: margin,
246            left: margin,
247        }
248    }
249
250    /// Create symmetric margin (vertical, horizontal)
251    pub fn symmetric(vertical: f64, horizontal: f64) -> Self {
252        Self {
253            top: vertical,
254            right: horizontal,
255            bottom: vertical,
256            left: horizontal,
257        }
258    }
259
260    /// Create individual margins
261    pub fn new(top: f64, right: f64, bottom: f64, left: f64) -> Self {
262        Self {
263            top,
264            right,
265            bottom,
266            left,
267        }
268    }
269
270    /// Get total horizontal margin
271    pub fn horizontal(&self) -> f64 {
272        self.left + self.right
273    }
274
275    /// Get total vertical margin
276    pub fn vertical(&self) -> f64 {
277        self.top + self.bottom
278    }
279}
280
281impl Default for ComponentMargin {
282    fn default() -> Self {
283        Self::uniform(8.0) // 8pt default margin
284    }
285}
286
287/// Base component configuration shared by all dashboard components
288#[derive(Debug, Clone)]
289pub struct ComponentConfig {
290    /// Column span in the grid
291    pub span: ComponentSpan,
292    /// Component alignment
293    pub alignment: ComponentAlignment,
294    /// Component margins
295    pub margin: ComponentMargin,
296    /// Optional custom ID for the component
297    pub id: Option<String>,
298    /// Whether the component is visible
299    pub visible: bool,
300    /// Custom CSS-like classes for advanced styling
301    pub classes: Vec<String>,
302}
303
304impl ComponentConfig {
305    /// Create a new component config with default values
306    pub fn new(span: ComponentSpan) -> Self {
307        Self {
308            span,
309            alignment: ComponentAlignment::default(),
310            margin: ComponentMargin::default(),
311            id: None,
312            visible: true,
313            classes: Vec::new(),
314        }
315    }
316
317    /// Set component alignment
318    pub fn with_alignment(mut self, alignment: ComponentAlignment) -> Self {
319        self.alignment = alignment;
320        self
321    }
322
323    /// Set component margin
324    pub fn with_margin(mut self, margin: ComponentMargin) -> Self {
325        self.margin = margin;
326        self
327    }
328
329    /// Set component ID
330    pub fn with_id(mut self, id: String) -> Self {
331        self.id = Some(id);
332        self
333    }
334
335    /// Add CSS-like class
336    pub fn with_class(mut self, class: String) -> Self {
337        self.classes.push(class);
338        self
339    }
340
341    /// Set visibility
342    pub fn with_visibility(mut self, visible: bool) -> Self {
343        self.visible = visible;
344        self
345    }
346}
347
348impl Default for ComponentConfig {
349    fn default() -> Self {
350        Self::new(ComponentSpan::new(12)) // Full width by default
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[test]
359    fn test_component_span() {
360        let span = ComponentSpan::new(6);
361        assert_eq!(span.columns, 6);
362        assert_eq!(span.as_fraction(), 0.5);
363        assert!(span.is_half_width());
364        assert!(!span.is_full_width());
365    }
366
367    #[test]
368    fn test_component_span_bounds() {
369        let span_too_large = ComponentSpan::new(15);
370        assert_eq!(span_too_large.columns, 12);
371
372        let span_too_small = ComponentSpan::new(0);
373        assert_eq!(span_too_small.columns, 1);
374    }
375
376    #[test]
377    fn test_component_position() {
378        let pos = ComponentPosition::new(100.0, 200.0, 300.0, 400.0);
379        let center = pos.center();
380
381        assert_eq!(center.x, 250.0);
382        assert_eq!(center.y, 400.0);
383        assert_eq!(pos.aspect_ratio(), 0.75);
384    }
385
386    #[test]
387    fn test_component_margin() {
388        let margin = ComponentMargin::uniform(10.0);
389        assert_eq!(margin.horizontal(), 20.0);
390        assert_eq!(margin.vertical(), 20.0);
391
392        let asymmetric = ComponentMargin::symmetric(5.0, 8.0);
393        assert_eq!(asymmetric.vertical(), 10.0);
394        assert_eq!(asymmetric.horizontal(), 16.0);
395    }
396
397    #[test]
398    fn test_component_config() {
399        let config = ComponentConfig::new(ComponentSpan::new(6))
400            .with_id("test-component".to_string())
401            .with_alignment(ComponentAlignment::Center)
402            .with_class("highlight".to_string());
403
404        assert_eq!(config.span.columns, 6);
405        assert_eq!(config.id, Some("test-component".to_string()));
406        assert_eq!(config.alignment, ComponentAlignment::Center);
407        assert!(config.classes.contains(&"highlight".to_string()));
408    }
409
410    #[test]
411    fn test_component_position_top_left() {
412        let pos = ComponentPosition::new(100.0, 200.0, 300.0, 400.0);
413        let top_left = pos.top_left();
414        assert_eq!(top_left.x, 100.0);
415        assert_eq!(top_left.y, 600.0); // y + height
416    }
417
418    #[test]
419    fn test_component_position_bottom_right() {
420        let pos = ComponentPosition::new(100.0, 200.0, 300.0, 400.0);
421        let bottom_right = pos.bottom_right();
422        assert_eq!(bottom_right.x, 400.0); // x + width
423        assert_eq!(bottom_right.y, 200.0);
424    }
425
426    #[test]
427    fn test_component_position_with_padding() {
428        let pos = ComponentPosition::new(100.0, 200.0, 300.0, 400.0);
429        let padded = pos.with_padding(10.0);
430
431        assert_eq!(padded.x, 110.0);
432        assert_eq!(padded.y, 210.0);
433        assert_eq!(padded.width, 280.0); // 300 - 2*10
434        assert_eq!(padded.height, 380.0); // 400 - 2*10
435    }
436
437    #[test]
438    fn test_component_position_contains() {
439        let pos = ComponentPosition::new(100.0, 200.0, 300.0, 400.0);
440
441        // Point inside
442        assert!(pos.contains(Point::new(200.0, 300.0)));
443
444        // Point on edges
445        assert!(pos.contains(Point::new(100.0, 200.0))); // bottom-left
446        assert!(pos.contains(Point::new(400.0, 600.0))); // top-right
447
448        // Point outside
449        assert!(!pos.contains(Point::new(50.0, 300.0))); // left
450        assert!(!pos.contains(Point::new(500.0, 300.0))); // right
451        assert!(!pos.contains(Point::new(200.0, 100.0))); // below
452        assert!(!pos.contains(Point::new(200.0, 700.0))); // above
453    }
454
455    #[test]
456    fn test_component_position_aspect_ratio_zero_height() {
457        let pos = ComponentPosition::new(100.0, 200.0, 300.0, 0.0);
458        assert_eq!(pos.aspect_ratio(), 1.0); // Default when height is 0
459    }
460
461    #[test]
462    fn test_component_span_with_rows() {
463        let span = ComponentSpan::with_rows(6, 2);
464        assert_eq!(span.columns, 6);
465        assert_eq!(span.rows, Some(2));
466
467        // Test clamping
468        let span_clamped = ComponentSpan::with_rows(15, 0);
469        assert_eq!(span_clamped.columns, 12);
470        assert_eq!(span_clamped.rows, Some(1));
471    }
472
473    #[test]
474    fn test_component_span_is_quarter_width() {
475        let span = ComponentSpan::new(3);
476        assert!(span.is_quarter_width());
477        assert!(!span.is_half_width());
478        assert!(!span.is_full_width());
479    }
480
481    #[test]
482    fn test_component_span_from_u8() {
483        let span: ComponentSpan = 4u8.into();
484        assert_eq!(span.columns, 4);
485        assert!(span.rows.is_none());
486    }
487
488    #[test]
489    fn test_component_alignment_debug() {
490        let alignments = vec![
491            ComponentAlignment::Start,
492            ComponentAlignment::Center,
493            ComponentAlignment::End,
494            ComponentAlignment::Stretch,
495        ];
496
497        for alignment in alignments {
498            let debug_str = format!("{:?}", alignment);
499            assert!(!debug_str.is_empty());
500        }
501    }
502
503    #[test]
504    fn test_component_alignment_default() {
505        let default = ComponentAlignment::default();
506        assert_eq!(default, ComponentAlignment::Stretch);
507    }
508
509    #[test]
510    fn test_component_margin_new() {
511        let margin = ComponentMargin::new(1.0, 2.0, 3.0, 4.0);
512        assert_eq!(margin.top, 1.0);
513        assert_eq!(margin.right, 2.0);
514        assert_eq!(margin.bottom, 3.0);
515        assert_eq!(margin.left, 4.0);
516    }
517
518    #[test]
519    fn test_component_margin_default() {
520        let default = ComponentMargin::default();
521        assert_eq!(default.top, 8.0);
522        assert_eq!(default.right, 8.0);
523        assert_eq!(default.bottom, 8.0);
524        assert_eq!(default.left, 8.0);
525    }
526
527    #[test]
528    fn test_component_config_default() {
529        let default = ComponentConfig::default();
530        assert_eq!(default.span.columns, 12);
531        assert_eq!(default.alignment, ComponentAlignment::Stretch);
532        assert!(default.visible);
533        assert!(default.classes.is_empty());
534        assert!(default.id.is_none());
535    }
536
537    #[test]
538    fn test_component_config_with_margin() {
539        let config =
540            ComponentConfig::new(ComponentSpan::new(6)).with_margin(ComponentMargin::uniform(16.0));
541
542        assert_eq!(config.margin.top, 16.0);
543        assert_eq!(config.margin.horizontal(), 32.0);
544    }
545
546    #[test]
547    fn test_component_config_with_visibility() {
548        let config = ComponentConfig::new(ComponentSpan::new(6)).with_visibility(false);
549
550        assert!(!config.visible);
551    }
552
553    #[test]
554    fn test_component_config_clone() {
555        let config = ComponentConfig::new(ComponentSpan::new(6))
556            .with_id("test".to_string())
557            .with_class("class1".to_string());
558
559        let cloned = config.clone();
560        assert_eq!(config.span, cloned.span);
561        assert_eq!(config.id, cloned.id);
562        assert_eq!(config.classes.len(), cloned.classes.len());
563    }
564
565    #[test]
566    fn test_component_position_clone_copy() {
567        let pos = ComponentPosition::new(10.0, 20.0, 30.0, 40.0);
568        let cloned = pos.clone();
569        let copied = pos;
570
571        assert_eq!(pos.x, cloned.x);
572        assert_eq!(pos.y, copied.y);
573    }
574
575    #[test]
576    fn test_component_span_equality() {
577        let span1 = ComponentSpan::new(6);
578        let span2 = ComponentSpan::new(6);
579        let span3 = ComponentSpan::new(8);
580
581        assert_eq!(span1, span2);
582        assert_ne!(span1, span3);
583    }
584
585    #[test]
586    fn test_component_margin_clone_copy() {
587        let margin = ComponentMargin::new(1.0, 2.0, 3.0, 4.0);
588        let cloned = margin.clone();
589        let copied = margin;
590
591        assert_eq!(margin.top, cloned.top);
592        assert_eq!(margin.left, copied.left);
593    }
594}