Skip to main content

presentar_terminal/widgets/
graph.rs

1//! Time-series graph widget with multiple render modes.
2
3use crate::theme::Gradient;
4use presentar_core::{
5    Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event,
6    LayoutResult, Point, Rect, Size, TextStyle, TypeId, Widget,
7};
8use std::any::Any;
9use std::time::Duration;
10
11/// Render mode for the graph.
12#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
13pub enum GraphMode {
14    /// Unicode braille characters (U+2800-28FF): 2x4 dots per cell.
15    #[default]
16    Braille,
17    /// Half-block characters (▀▄█): 1x2 resolution per cell.
18    Block,
19    /// Pure ASCII characters: TTY compatible.
20    Tty,
21}
22
23/// UX-117: Time axis display mode.
24#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
25pub enum TimeAxisMode {
26    /// Show numeric indices (0, 1, 2, ...).
27    #[default]
28    Indices,
29    /// Show relative time (1m, 2m, 5m ago).
30    Relative {
31        /// Seconds per data point.
32        interval_secs: u64,
33    },
34    /// Show absolute time (HH:MM:SS).
35    Absolute,
36    /// Hide X-axis labels.
37    Hidden,
38}
39
40impl TimeAxisMode {
41    /// Format a time offset as a label.
42    pub fn format_label(&self, index: usize, total: usize) -> Option<String> {
43        match self {
44            Self::Indices => Some(format!("{index}")),
45            Self::Relative { interval_secs } => {
46                let secs_ago = (total - index) as u64 * interval_secs;
47                if secs_ago < 60 {
48                    Some(format!("{secs_ago}s"))
49                } else if secs_ago < 3600 {
50                    Some(format!("{}m", secs_ago / 60))
51                } else {
52                    Some(format!("{}h", secs_ago / 3600))
53                }
54            }
55            Self::Absolute | Self::Hidden => None, // Would need actual timestamp
56        }
57    }
58}
59
60/// UX-102: Axis margin configuration.
61#[derive(Debug, Clone, Copy)]
62pub struct AxisMargins {
63    /// Width for Y-axis labels (in characters).
64    pub y_axis_width: u16,
65    /// Height for X-axis labels (in lines).
66    pub x_axis_height: u16,
67}
68
69impl Default for AxisMargins {
70    fn default() -> Self {
71        Self {
72            y_axis_width: 6,
73            x_axis_height: 1,
74        }
75    }
76}
77
78impl AxisMargins {
79    /// No margins (labels overlap content).
80    pub const NONE: Self = Self {
81        y_axis_width: 0,
82        x_axis_height: 0,
83    };
84
85    /// Compact margins.
86    pub const COMPACT: Self = Self {
87        y_axis_width: 4,
88        x_axis_height: 1,
89    };
90
91    /// Standard margins.
92    pub const STANDARD: Self = Self {
93        y_axis_width: 6,
94        x_axis_height: 1,
95    };
96
97    /// Wide margins for large numbers.
98    pub const WIDE: Self = Self {
99        y_axis_width: 10,
100        x_axis_height: 2,
101    };
102}
103
104/// Time-series graph widget.
105#[derive(Debug, Clone)]
106pub struct BrailleGraph {
107    data: Vec<f64>,
108    color: Color,
109    /// Optional gradient for per-column coloring based on value.
110    gradient: Option<Gradient>,
111    min: f64,
112    max: f64,
113    mode: GraphMode,
114    label: Option<String>,
115    /// UX-102: Axis margin configuration.
116    margins: AxisMargins,
117    /// UX-117: Time axis display mode.
118    time_axis: TimeAxisMode,
119    /// UX-104: Show legend for braille characters.
120    show_legend: bool,
121    bounds: Rect,
122}
123
124impl BrailleGraph {
125    /// Create a new braille graph.
126    #[must_use]
127    pub fn new(data: Vec<f64>) -> Self {
128        let (min, max) = Self::compute_range(&data);
129        Self {
130            data,
131            color: Color::GREEN,
132            gradient: None,
133            min,
134            max,
135            mode: GraphMode::default(),
136            label: None,
137            margins: AxisMargins::default(),
138            time_axis: TimeAxisMode::default(),
139            show_legend: false,
140            bounds: Rect::new(0.0, 0.0, 0.0, 0.0),
141        }
142    }
143
144    /// Set the color.
145    #[must_use]
146    pub fn with_color(mut self, color: Color) -> Self {
147        self.color = color;
148        self
149    }
150
151    /// Set a gradient for per-column coloring based on value.
152    /// When set, each column is colored based on its normalized value (0.0-1.0).
153    #[must_use]
154    pub fn with_gradient(mut self, gradient: Gradient) -> Self {
155        self.gradient = Some(gradient);
156        self
157    }
158
159    /// Set explicit min/max range.
160    #[must_use]
161    pub fn with_range(mut self, min: f64, max: f64) -> Self {
162        debug_assert!(min.is_finite(), "min must be finite");
163        debug_assert!(max.is_finite(), "max must be finite");
164        self.min = min;
165        self.max = max;
166        self
167    }
168
169    /// Set the render mode.
170    #[must_use]
171    pub fn with_mode(mut self, mode: GraphMode) -> Self {
172        self.mode = mode;
173        self
174    }
175
176    /// Set the label.
177    #[must_use]
178    pub fn with_label(mut self, label: impl Into<String>) -> Self {
179        self.label = Some(label.into());
180        self
181    }
182
183    /// UX-102: Set axis margins.
184    #[must_use]
185    pub fn with_margins(mut self, margins: AxisMargins) -> Self {
186        self.margins = margins;
187        self
188    }
189
190    /// UX-117: Set time axis display mode.
191    #[must_use]
192    pub fn with_time_axis(mut self, mode: TimeAxisMode) -> Self {
193        self.time_axis = mode;
194        self
195    }
196
197    /// UX-104: Enable/disable legend display.
198    #[must_use]
199    pub fn with_legend(mut self, show: bool) -> Self {
200        self.show_legend = show;
201        self
202    }
203
204    /// Get the effective graph area after accounting for margins.
205    fn graph_area(&self) -> Rect {
206        let y_offset = self.margins.y_axis_width as f32;
207        let x_height = self.margins.x_axis_height as f32;
208        Rect::new(
209            self.bounds.x + y_offset,
210            self.bounds.y,
211            (self.bounds.width - y_offset).max(0.0),
212            (self.bounds.height - x_height).max(0.0),
213        )
214    }
215
216    /// Render Y-axis labels in the margin.
217    fn render_y_axis(&self, canvas: &mut dyn Canvas) {
218        if self.margins.y_axis_width == 0 {
219            return;
220        }
221
222        let style = TextStyle {
223            color: Color::WHITE,
224            ..Default::default()
225        };
226
227        // Max value at top
228        let max_str = format!("{:.0}", self.max);
229        canvas.draw_text(&max_str, Point::new(self.bounds.x, self.bounds.y), &style);
230
231        // Min value at bottom of graph area
232        let graph_height = (self.bounds.height - self.margins.x_axis_height as f32).max(1.0);
233        let min_str = format!("{:.0}", self.min);
234        canvas.draw_text(
235            &min_str,
236            Point::new(self.bounds.x, self.bounds.y + graph_height - 1.0),
237            &style,
238        );
239    }
240
241    /// Render X-axis time labels.
242    fn render_x_axis(&self, canvas: &mut dyn Canvas) {
243        if self.margins.x_axis_height == 0 {
244            return;
245        }
246        if matches!(self.time_axis, TimeAxisMode::Hidden) {
247            return;
248        }
249
250        let graph = self.graph_area();
251        let y_pos = self.bounds.y + self.bounds.height - 1.0;
252        let total = self.data.len();
253
254        let style = TextStyle {
255            color: Color::WHITE,
256            ..Default::default()
257        };
258
259        // Show labels at start, middle, and end
260        let positions = [0, total / 2, total.saturating_sub(1)];
261        for &idx in &positions {
262            if let Some(label) = self.time_axis.format_label(idx, total) {
263                let x_frac = if total > 1 {
264                    idx as f32 / (total - 1) as f32
265                } else {
266                    0.5
267                };
268                let x_pos = graph.x + x_frac * (graph.width - 1.0).max(0.0);
269                canvas.draw_text(&label, Point::new(x_pos, y_pos), &style);
270            }
271        }
272    }
273
274    /// Render legend explaining braille patterns.
275    fn render_legend(&self, canvas: &mut dyn Canvas) {
276        if !self.show_legend {
277            return;
278        }
279
280        let style = TextStyle {
281            color: Color::WHITE,
282            ..Default::default()
283        };
284
285        // Simple legend showing value mapping
286        let legend = format!("⣿={:.0} ⣀={:.0}", self.max, self.min);
287        let x = self.bounds.x + self.bounds.width - legend.len() as f32;
288        canvas.draw_text(&legend, Point::new(x.max(0.0), self.bounds.y), &style);
289    }
290
291    /// Update the data.
292    pub fn set_data(&mut self, data: Vec<f64>) {
293        let (min, max) = Self::compute_range(&data);
294        self.data = data;
295        self.min = min;
296        self.max = max;
297    }
298
299    /// Push a new data point.
300    pub fn push(&mut self, value: f64) {
301        self.data.push(value);
302        if value < self.min {
303            self.min = value;
304        }
305        if value > self.max {
306            self.max = value;
307        }
308    }
309
310    fn compute_range(data: &[f64]) -> (f64, f64) {
311        if data.is_empty() {
312            return (0.0, 1.0);
313        }
314        let min = data.iter().fold(f64::INFINITY, |a, &b| a.min(b));
315        let max = data.iter().fold(f64::NEG_INFINITY, |a, &b| a.max(b));
316        if (max - min).abs() < f64::EPSILON {
317            (min - 0.5, max + 0.5)
318        } else {
319            (min, max)
320        }
321    }
322
323    fn normalize(&self, value: f64) -> f64 {
324        if (self.max - self.min).abs() < f64::EPSILON {
325            0.5
326        } else {
327            (value - self.min) / (self.max - self.min)
328        }
329    }
330
331    /// Get color for a normalized value (0.0-1.0).
332    /// Uses gradient if set, otherwise returns the fixed color.
333    fn color_for_value(&self, normalized: f64) -> Color {
334        match &self.gradient {
335            Some(gradient) => gradient.sample(normalized),
336            None => self.color,
337        }
338    }
339
340    fn render_braille(&self, canvas: &mut dyn Canvas) {
341        let width = self.bounds.width as usize;
342        let height = self.bounds.height as usize;
343        if width == 0 || height == 0 || self.data.is_empty() {
344            return;
345        }
346
347        let dots_per_col = 2;
348        let dots_per_row = 4;
349        let total_dots_x = width * dots_per_col;
350        let total_dots_y = height * dots_per_row;
351
352        let step = if self.data.len() > total_dots_x {
353            self.data.len() as f64 / total_dots_x as f64
354        } else {
355            1.0
356        };
357
358        // Track dots and values per column for gradient coloring
359        let mut dots = vec![vec![false; total_dots_x]; total_dots_y];
360        let mut column_values: Vec<f64> = vec![0.0; width];
361
362        for (i, x) in (0..total_dots_x).enumerate() {
363            let data_idx = (i as f64 * step) as usize;
364            if data_idx >= self.data.len() {
365                break;
366            }
367            let value = self.normalize(self.data[data_idx]);
368            let y = ((1.0 - value) * (total_dots_y - 1) as f64).round() as usize;
369            if y < total_dots_y {
370                dots[y][x] = true;
371            }
372            // Track max value for each character column
373            let char_col = x / dots_per_col;
374            if char_col < width && value > column_values[char_col] {
375                column_values[char_col] = value;
376            }
377        }
378
379        for cy in 0..height {
380            for (cx, &col_value) in column_values.iter().enumerate().take(width) {
381                let mut code_point = 0x2800u32;
382                let dot_offsets = [
383                    (0, 0, 0x01),
384                    (0, 1, 0x02),
385                    (0, 2, 0x04),
386                    (1, 0, 0x08),
387                    (1, 1, 0x10),
388                    (1, 2, 0x20),
389                    (0, 3, 0x40),
390                    (1, 3, 0x80),
391                ];
392
393                for (dx, dy, bit) in dot_offsets {
394                    let dot_x = cx * dots_per_col + dx;
395                    let dot_y = cy * dots_per_row + dy;
396                    if dot_y < total_dots_y && dot_x < total_dots_x && dots[dot_y][dot_x] {
397                        code_point |= bit;
398                    }
399                }
400
401                if let Some(c) = char::from_u32(code_point) {
402                    // Use per-column color based on value
403                    let color = self.color_for_value(col_value);
404                    let style = TextStyle {
405                        color,
406                        ..Default::default()
407                    };
408                    canvas.draw_text(
409                        &c.to_string(),
410                        Point::new(self.bounds.x + cx as f32, self.bounds.y + cy as f32),
411                        &style,
412                    );
413                }
414            }
415        }
416    }
417
418    fn render_block(&self, canvas: &mut dyn Canvas) {
419        let width = self.bounds.width as usize;
420        let height = self.bounds.height as usize;
421        if width == 0 || height == 0 || self.data.is_empty() {
422            return;
423        }
424
425        let total_rows = height * 2;
426
427        let step = if self.data.len() > width {
428            self.data.len() as f64 / width as f64
429        } else {
430            1.0
431        };
432
433        // Track both row position and normalized value for each column
434        let mut column_data: Vec<(usize, f64)> = Vec::with_capacity(width);
435        for x in 0..width {
436            let data_idx = (x as f64 * step) as usize;
437            if data_idx >= self.data.len() {
438                column_data.push((total_rows, 0.0));
439                continue;
440            }
441            let value = self.normalize(self.data[data_idx]);
442            let row = ((1.0 - value) * (total_rows - 1) as f64).round() as usize;
443            column_data.push((row.min(total_rows - 1), value));
444        }
445
446        for cy in 0..height {
447            for cx in 0..width {
448                let (value_row, normalized) =
449                    column_data.get(cx).copied().unwrap_or((total_rows, 0.0));
450                let top_row = cy * 2;
451                let bottom_row = cy * 2 + 1;
452
453                let top_filled = value_row <= top_row;
454                let bottom_filled = value_row <= bottom_row;
455
456                let ch = match (top_filled, bottom_filled) {
457                    (true, true) => '█',
458                    (true, false) => '▀',
459                    (false, true) => '▄',
460                    (false, false) => ' ',
461                };
462
463                // Use per-column color based on value
464                let color = self.color_for_value(normalized);
465                let style = TextStyle {
466                    color,
467                    ..Default::default()
468                };
469                canvas.draw_text(
470                    &ch.to_string(),
471                    Point::new(self.bounds.x + cx as f32, self.bounds.y + cy as f32),
472                    &style,
473                );
474            }
475        }
476    }
477
478    fn render_tty(&self, canvas: &mut dyn Canvas) {
479        let width = self.bounds.width as usize;
480        let height = self.bounds.height as usize;
481        if width == 0 || height == 0 || self.data.is_empty() {
482            return;
483        }
484
485        let step = if self.data.len() > width {
486            self.data.len() as f64 / width as f64
487        } else {
488            1.0
489        };
490
491        // Track both row position and normalized value for each column
492        let mut column_data: Vec<(usize, f64)> = Vec::with_capacity(width);
493        for x in 0..width {
494            let data_idx = (x as f64 * step) as usize;
495            if data_idx >= self.data.len() {
496                column_data.push((height, 0.0));
497                continue;
498            }
499            let value = self.normalize(self.data[data_idx]);
500            let row = ((1.0 - value) * (height - 1) as f64).round() as usize;
501            column_data.push((row.min(height - 1), value));
502        }
503
504        for cy in 0..height {
505            for cx in 0..width {
506                let (value_row, normalized) = column_data.get(cx).copied().unwrap_or((height, 0.0));
507                let ch = if value_row == cy { '*' } else { ' ' };
508
509                // Use per-column color based on value
510                let color = self.color_for_value(normalized);
511                let style = TextStyle {
512                    color,
513                    ..Default::default()
514                };
515                canvas.draw_text(
516                    &ch.to_string(),
517                    Point::new(self.bounds.x + cx as f32, self.bounds.y + cy as f32),
518                    &style,
519                );
520            }
521        }
522    }
523}
524
525impl Brick for BrailleGraph {
526    fn brick_name(&self) -> &'static str {
527        "braille_graph"
528    }
529
530    fn assertions(&self) -> &[BrickAssertion] {
531        static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(16)];
532        ASSERTIONS
533    }
534
535    fn budget(&self) -> BrickBudget {
536        BrickBudget::uniform(16)
537    }
538
539    fn verify(&self) -> BrickVerification {
540        BrickVerification {
541            passed: vec![BrickAssertion::max_latency_ms(16)],
542            failed: vec![],
543            verification_time: Duration::from_micros(10),
544        }
545    }
546
547    fn to_html(&self) -> String {
548        String::new() // TUI-only widget
549    }
550
551    fn to_css(&self) -> String {
552        String::new() // TUI-only widget
553    }
554}
555
556impl Widget for BrailleGraph {
557    fn type_id(&self) -> TypeId {
558        TypeId::of::<Self>()
559    }
560
561    fn measure(&self, constraints: Constraints) -> Size {
562        let width = constraints.max_width.max(10.0);
563        let height = constraints.max_height.max(3.0);
564        constraints.constrain(Size::new(width, height))
565    }
566
567    fn layout(&mut self, bounds: Rect) -> LayoutResult {
568        self.bounds = bounds;
569        LayoutResult {
570            size: Size::new(bounds.width, bounds.height),
571        }
572    }
573
574    fn paint(&self, canvas: &mut dyn Canvas) {
575        // Early return if bounds are too small or data is empty
576        if self.bounds.width < 1.0 || self.bounds.height < 1.0 || self.data.is_empty() {
577            return;
578        }
579
580        // UX-102: Render Y-axis labels in margin
581        self.render_y_axis(canvas);
582
583        // UX-117: Render X-axis time labels
584        self.render_x_axis(canvas);
585
586        // UX-104: Render legend
587        self.render_legend(canvas);
588
589        // Render the graph data
590        match self.mode {
591            GraphMode::Braille => self.render_braille(canvas),
592            GraphMode::Block => self.render_block(canvas),
593            GraphMode::Tty => self.render_tty(canvas),
594        }
595
596        if let Some(ref label) = self.label {
597            let style = TextStyle {
598                color: self.color,
599                ..Default::default()
600            };
601            canvas.draw_text(label, Point::new(self.bounds.x, self.bounds.y), &style);
602        }
603    }
604
605    fn event(&mut self, _event: &Event) -> Option<Box<dyn Any + Send>> {
606        None
607    }
608
609    fn children(&self) -> &[Box<dyn Widget>] {
610        &[]
611    }
612
613    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
614        &mut []
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621    use presentar_core::{Canvas, TextStyle};
622
623    struct MockCanvas {
624        texts: Vec<(String, Point)>,
625    }
626
627    impl MockCanvas {
628        fn new() -> Self {
629            Self { texts: vec![] }
630        }
631    }
632
633    impl Canvas for MockCanvas {
634        fn fill_rect(&mut self, _rect: Rect, _color: Color) {}
635        fn stroke_rect(&mut self, _rect: Rect, _color: Color, _width: f32) {}
636        fn draw_text(&mut self, text: &str, position: Point, _style: &TextStyle) {
637            self.texts.push((text.to_string(), position));
638        }
639        fn draw_line(&mut self, _from: Point, _to: Point, _color: Color, _width: f32) {}
640        fn fill_circle(&mut self, _center: Point, _radius: f32, _color: Color) {}
641        fn stroke_circle(&mut self, _center: Point, _radius: f32, _color: Color, _width: f32) {}
642        fn fill_arc(
643            &mut self,
644            _center: Point,
645            _radius: f32,
646            _start: f32,
647            _end: f32,
648            _color: Color,
649        ) {
650        }
651        fn draw_path(&mut self, _points: &[Point], _color: Color, _width: f32) {}
652        fn fill_polygon(&mut self, _points: &[Point], _color: Color) {}
653        fn push_clip(&mut self, _rect: Rect) {}
654        fn pop_clip(&mut self) {}
655        fn push_transform(&mut self, _transform: presentar_core::Transform2D) {}
656        fn pop_transform(&mut self) {}
657    }
658
659    #[test]
660    fn test_graph_creation() {
661        let graph = BrailleGraph::new(vec![1.0, 2.0, 3.0]);
662        assert_eq!(graph.data.len(), 3);
663    }
664
665    #[test]
666    fn test_graph_assertions_not_empty() {
667        let graph = BrailleGraph::new(vec![1.0, 2.0, 3.0]);
668        assert!(!graph.assertions().is_empty());
669    }
670
671    #[test]
672    fn test_graph_verify_pass() {
673        let graph = BrailleGraph::new(vec![1.0, 2.0, 3.0]);
674        assert!(graph.verify().is_valid());
675    }
676
677    #[test]
678    fn test_graph_with_color() {
679        let graph = BrailleGraph::new(vec![1.0, 2.0]).with_color(Color::RED);
680        assert_eq!(graph.color, Color::RED);
681    }
682
683    #[test]
684    fn test_graph_with_range() {
685        let graph = BrailleGraph::new(vec![1.0, 2.0]).with_range(0.0, 100.0);
686        assert_eq!(graph.min, 0.0);
687        assert_eq!(graph.max, 100.0);
688    }
689
690    #[test]
691    fn test_graph_with_mode() {
692        let graph = BrailleGraph::new(vec![1.0]).with_mode(GraphMode::Block);
693        assert_eq!(graph.mode, GraphMode::Block);
694
695        let graph2 = BrailleGraph::new(vec![1.0]).with_mode(GraphMode::Tty);
696        assert_eq!(graph2.mode, GraphMode::Tty);
697    }
698
699    #[test]
700    fn test_graph_with_label() {
701        let graph = BrailleGraph::new(vec![1.0]).with_label("CPU Usage");
702        assert_eq!(graph.label, Some("CPU Usage".to_string()));
703    }
704
705    #[test]
706    fn test_graph_set_data() {
707        let mut graph = BrailleGraph::new(vec![1.0, 2.0]);
708        graph.set_data(vec![10.0, 20.0, 30.0, 40.0]);
709        assert_eq!(graph.data.len(), 4);
710        assert_eq!(graph.min, 10.0);
711        assert_eq!(graph.max, 40.0);
712    }
713
714    #[test]
715    fn test_graph_push() {
716        let mut graph = BrailleGraph::new(vec![5.0, 10.0]);
717        graph.push(15.0);
718        assert_eq!(graph.data.len(), 3);
719        assert_eq!(graph.max, 15.0);
720
721        graph.push(2.0);
722        assert_eq!(graph.min, 2.0);
723    }
724
725    #[test]
726    fn test_graph_empty_data_range() {
727        let graph = BrailleGraph::new(vec![]);
728        assert_eq!(graph.min, 0.0);
729        assert_eq!(graph.max, 1.0);
730    }
731
732    #[test]
733    fn test_graph_constant_data_range() {
734        let graph = BrailleGraph::new(vec![5.0, 5.0, 5.0]);
735        assert_eq!(graph.min, 4.5);
736        assert_eq!(graph.max, 5.5);
737    }
738
739    #[test]
740    fn test_graph_normalize() {
741        let graph = BrailleGraph::new(vec![0.0, 100.0]);
742        assert!((graph.normalize(50.0) - 0.5).abs() < f64::EPSILON);
743        assert!((graph.normalize(0.0) - 0.0).abs() < f64::EPSILON);
744        assert!((graph.normalize(100.0) - 1.0).abs() < f64::EPSILON);
745    }
746
747    #[test]
748    fn test_graph_normalize_constant() {
749        let graph = BrailleGraph::new(vec![5.0, 5.0]);
750        assert!((graph.normalize(5.0) - 0.5).abs() < f64::EPSILON);
751    }
752
753    #[test]
754    fn test_graph_measure() {
755        let graph = BrailleGraph::new(vec![1.0, 2.0]);
756        let constraints = Constraints::new(0.0, 100.0, 0.0, 50.0);
757        let size = graph.measure(constraints);
758        assert!(size.width >= 10.0);
759        assert!(size.height >= 3.0);
760    }
761
762    #[test]
763    fn test_graph_layout() {
764        let mut graph = BrailleGraph::new(vec![1.0, 2.0]);
765        let bounds = Rect::new(10.0, 20.0, 80.0, 24.0);
766        let result = graph.layout(bounds);
767        assert_eq!(result.size.width, 80.0);
768        assert_eq!(result.size.height, 24.0);
769        assert_eq!(graph.bounds, bounds);
770    }
771
772    #[test]
773    fn test_graph_paint_braille() {
774        let mut graph = BrailleGraph::new(vec![0.0, 50.0, 100.0]).with_mode(GraphMode::Braille);
775        graph.bounds = Rect::new(0.0, 0.0, 10.0, 5.0);
776        let mut canvas = MockCanvas::new();
777        graph.paint(&mut canvas);
778        assert!(!canvas.texts.is_empty());
779    }
780
781    #[test]
782    fn test_graph_paint_block() {
783        let mut graph = BrailleGraph::new(vec![0.0, 50.0, 100.0]).with_mode(GraphMode::Block);
784        graph.bounds = Rect::new(0.0, 0.0, 10.0, 5.0);
785        let mut canvas = MockCanvas::new();
786        graph.paint(&mut canvas);
787        assert!(!canvas.texts.is_empty());
788    }
789
790    #[test]
791    fn test_graph_paint_tty() {
792        let mut graph = BrailleGraph::new(vec![0.0, 50.0, 100.0]).with_mode(GraphMode::Tty);
793        graph.bounds = Rect::new(0.0, 0.0, 10.0, 5.0);
794        let mut canvas = MockCanvas::new();
795        graph.paint(&mut canvas);
796        assert!(!canvas.texts.is_empty());
797    }
798
799    #[test]
800    fn test_graph_paint_with_label() {
801        let mut graph = BrailleGraph::new(vec![1.0, 2.0]).with_label("Test");
802        graph.bounds = Rect::new(0.0, 0.0, 20.0, 10.0);
803        let mut canvas = MockCanvas::new();
804        graph.paint(&mut canvas);
805        assert!(canvas.texts.iter().any(|(t, _)| t.contains("Test")));
806    }
807
808    #[test]
809    fn test_graph_paint_empty_bounds() {
810        let mut graph = BrailleGraph::new(vec![1.0, 2.0]);
811        graph.bounds = Rect::new(0.0, 0.0, 0.0, 0.0);
812        let mut canvas = MockCanvas::new();
813        graph.paint(&mut canvas);
814        assert!(canvas.texts.is_empty());
815    }
816
817    #[test]
818    fn test_graph_paint_empty_data() {
819        let mut graph = BrailleGraph::new(vec![]);
820        graph.bounds = Rect::new(0.0, 0.0, 10.0, 5.0);
821        let mut canvas = MockCanvas::new();
822        graph.paint(&mut canvas);
823        assert!(canvas.texts.is_empty());
824    }
825
826    #[test]
827    fn test_graph_event() {
828        let mut graph = BrailleGraph::new(vec![1.0]);
829        let event = Event::KeyDown {
830            key: presentar_core::Key::Enter,
831        };
832        assert!(graph.event(&event).is_none());
833    }
834
835    #[test]
836    fn test_graph_children() {
837        let graph = BrailleGraph::new(vec![1.0]);
838        assert!(graph.children().is_empty());
839    }
840
841    #[test]
842    fn test_graph_children_mut() {
843        let mut graph = BrailleGraph::new(vec![1.0]);
844        assert!(graph.children_mut().is_empty());
845    }
846
847    #[test]
848    fn test_graph_type_id() {
849        let graph = BrailleGraph::new(vec![1.0]);
850        assert_eq!(Widget::type_id(&graph), TypeId::of::<BrailleGraph>());
851    }
852
853    #[test]
854    fn test_graph_brick_name() {
855        let graph = BrailleGraph::new(vec![1.0]);
856        assert_eq!(graph.brick_name(), "braille_graph");
857    }
858
859    #[test]
860    fn test_graph_budget() {
861        let graph = BrailleGraph::new(vec![1.0]);
862        let budget = graph.budget();
863        assert!(budget.measure_ms > 0);
864    }
865
866    #[test]
867    fn test_graph_to_html() {
868        let graph = BrailleGraph::new(vec![1.0]);
869        assert!(graph.to_html().is_empty());
870    }
871
872    #[test]
873    fn test_graph_to_css() {
874        let graph = BrailleGraph::new(vec![1.0]);
875        assert!(graph.to_css().is_empty());
876    }
877
878    #[test]
879    fn test_graph_mode_default() {
880        assert_eq!(GraphMode::default(), GraphMode::Braille);
881    }
882
883    #[test]
884    fn test_graph_large_dataset() {
885        let data: Vec<f64> = (0..1000).map(|i| (i as f64).sin()).collect();
886        let mut graph = BrailleGraph::new(data);
887        graph.bounds = Rect::new(0.0, 0.0, 50.0, 10.0);
888        let mut canvas = MockCanvas::new();
889        graph.paint(&mut canvas);
890        assert!(!canvas.texts.is_empty());
891    }
892
893    #[test]
894    fn test_graph_block_mode_various_values() {
895        let mut graph =
896            BrailleGraph::new(vec![0.0, 25.0, 50.0, 75.0, 100.0]).with_mode(GraphMode::Block);
897        graph.bounds = Rect::new(0.0, 0.0, 5.0, 4.0);
898        let mut canvas = MockCanvas::new();
899        graph.paint(&mut canvas);
900        assert!(!canvas.texts.is_empty());
901    }
902
903    #[test]
904    fn test_graph_tty_mode_various_values() {
905        let mut graph =
906            BrailleGraph::new(vec![0.0, 25.0, 50.0, 75.0, 100.0]).with_mode(GraphMode::Tty);
907        graph.bounds = Rect::new(0.0, 0.0, 5.0, 4.0);
908        let mut canvas = MockCanvas::new();
909        graph.paint(&mut canvas);
910        assert!(!canvas.texts.is_empty());
911    }
912
913    // ========================================================================
914    // Additional tests for axis margins and time axis
915    // ========================================================================
916
917    #[test]
918    fn test_graph_with_margins() {
919        let graph = BrailleGraph::new(vec![1.0, 2.0]).with_margins(AxisMargins::WIDE);
920        assert_eq!(graph.margins.y_axis_width, 10);
921        assert_eq!(graph.margins.x_axis_height, 2);
922    }
923
924    #[test]
925    fn test_graph_with_margins_none() {
926        let graph = BrailleGraph::new(vec![1.0, 2.0]).with_margins(AxisMargins::NONE);
927        assert_eq!(graph.margins.y_axis_width, 0);
928        assert_eq!(graph.margins.x_axis_height, 0);
929    }
930
931    #[test]
932    fn test_graph_with_margins_compact() {
933        let graph = BrailleGraph::new(vec![1.0, 2.0]).with_margins(AxisMargins::COMPACT);
934        assert_eq!(graph.margins.y_axis_width, 4);
935        assert_eq!(graph.margins.x_axis_height, 1);
936    }
937
938    #[test]
939    fn test_graph_with_margins_standard() {
940        let graph = BrailleGraph::new(vec![1.0, 2.0]).with_margins(AxisMargins::STANDARD);
941        assert_eq!(graph.margins.y_axis_width, 6);
942        assert_eq!(graph.margins.x_axis_height, 1);
943    }
944
945    #[test]
946    fn test_axis_margins_default() {
947        let margins = AxisMargins::default();
948        assert_eq!(margins.y_axis_width, 6);
949        assert_eq!(margins.x_axis_height, 1);
950    }
951
952    #[test]
953    fn test_graph_with_time_axis_indices() {
954        let graph = BrailleGraph::new(vec![1.0, 2.0]).with_time_axis(TimeAxisMode::Indices);
955        assert_eq!(graph.time_axis, TimeAxisMode::Indices);
956    }
957
958    #[test]
959    fn test_graph_with_time_axis_relative() {
960        let graph = BrailleGraph::new(vec![1.0, 2.0])
961            .with_time_axis(TimeAxisMode::Relative { interval_secs: 5 });
962        match graph.time_axis {
963            TimeAxisMode::Relative { interval_secs } => assert_eq!(interval_secs, 5),
964            _ => panic!("Expected Relative time axis mode"),
965        }
966    }
967
968    #[test]
969    fn test_graph_with_time_axis_absolute() {
970        let graph = BrailleGraph::new(vec![1.0, 2.0]).with_time_axis(TimeAxisMode::Absolute);
971        assert_eq!(graph.time_axis, TimeAxisMode::Absolute);
972    }
973
974    #[test]
975    fn test_graph_with_time_axis_hidden() {
976        let graph = BrailleGraph::new(vec![1.0, 2.0]).with_time_axis(TimeAxisMode::Hidden);
977        assert_eq!(graph.time_axis, TimeAxisMode::Hidden);
978    }
979
980    #[test]
981    fn test_time_axis_mode_default() {
982        assert_eq!(TimeAxisMode::default(), TimeAxisMode::Indices);
983    }
984
985    #[test]
986    fn test_time_axis_format_label_indices() {
987        let mode = TimeAxisMode::Indices;
988        assert_eq!(mode.format_label(0, 10), Some("0".to_string()));
989        assert_eq!(mode.format_label(5, 10), Some("5".to_string()));
990        assert_eq!(mode.format_label(9, 10), Some("9".to_string()));
991    }
992
993    #[test]
994    fn test_time_axis_format_label_relative_seconds() {
995        let mode = TimeAxisMode::Relative { interval_secs: 1 };
996        // At index 0 with total 60, that's 60 seconds ago
997        assert_eq!(mode.format_label(0, 60), Some("1m".to_string()));
998        // At index 59 with total 60, that's 1 second ago
999        assert_eq!(mode.format_label(59, 60), Some("1s".to_string()));
1000        // At index 30 with total 60, that's 30 seconds ago
1001        assert_eq!(mode.format_label(30, 60), Some("30s".to_string()));
1002    }
1003
1004    #[test]
1005    fn test_time_axis_format_label_relative_minutes() {
1006        let mode = TimeAxisMode::Relative { interval_secs: 60 };
1007        // At index 0 with total 10, that's 600 seconds (10 minutes) ago
1008        assert_eq!(mode.format_label(0, 10), Some("10m".to_string()));
1009        // At index 5 with total 10, that's 300 seconds (5 minutes) ago
1010        assert_eq!(mode.format_label(5, 10), Some("5m".to_string()));
1011    }
1012
1013    #[test]
1014    fn test_time_axis_format_label_relative_hours() {
1015        let mode = TimeAxisMode::Relative {
1016            interval_secs: 3600,
1017        };
1018        // At index 0 with total 5, that's 18000 seconds (5 hours) ago
1019        assert_eq!(mode.format_label(0, 5), Some("5h".to_string()));
1020        // At index 3 with total 5, that's 7200 seconds (2 hours) ago
1021        assert_eq!(mode.format_label(3, 5), Some("2h".to_string()));
1022    }
1023
1024    #[test]
1025    fn test_time_axis_format_label_absolute() {
1026        let mode = TimeAxisMode::Absolute;
1027        assert_eq!(mode.format_label(0, 10), None);
1028    }
1029
1030    #[test]
1031    fn test_time_axis_format_label_hidden() {
1032        let mode = TimeAxisMode::Hidden;
1033        assert_eq!(mode.format_label(0, 10), None);
1034    }
1035
1036    #[test]
1037    fn test_graph_with_legend() {
1038        let graph = BrailleGraph::new(vec![1.0, 2.0]).with_legend(true);
1039        assert!(graph.show_legend);
1040    }
1041
1042    #[test]
1043    fn test_graph_with_legend_disabled() {
1044        let graph = BrailleGraph::new(vec![1.0, 2.0]).with_legend(false);
1045        assert!(!graph.show_legend);
1046    }
1047
1048    #[test]
1049    fn test_graph_with_gradient() {
1050        let gradient = Gradient::two(Color::BLUE, Color::RED);
1051        let graph = BrailleGraph::new(vec![1.0, 2.0]).with_gradient(gradient);
1052        assert!(graph.gradient.is_some());
1053    }
1054
1055    #[test]
1056    fn test_graph_color_for_value_without_gradient() {
1057        let graph = BrailleGraph::new(vec![0.0, 100.0]).with_color(Color::GREEN);
1058        let color = graph.color_for_value(0.5);
1059        assert_eq!(color, Color::GREEN);
1060    }
1061
1062    #[test]
1063    fn test_graph_color_for_value_with_gradient() {
1064        let gradient = Gradient::two(Color::BLUE, Color::RED);
1065        let graph = BrailleGraph::new(vec![0.0, 100.0]).with_gradient(gradient);
1066        // Should get different colors at different positions
1067        let color_low = graph.color_for_value(0.0);
1068        let color_high = graph.color_for_value(1.0);
1069        // Colors should differ (one is blue, one is red)
1070        assert_ne!(color_low, color_high);
1071    }
1072
1073    #[test]
1074    fn test_graph_area_with_margins() {
1075        let mut graph = BrailleGraph::new(vec![1.0, 2.0]).with_margins(AxisMargins::STANDARD);
1076        graph.bounds = Rect::new(0.0, 0.0, 80.0, 24.0);
1077        let area = graph.graph_area();
1078        // y_axis_width = 6, so x starts at 6
1079        assert_eq!(area.x, 6.0);
1080        // x_axis_height = 1, so height reduced by 1
1081        assert_eq!(area.height, 23.0);
1082        assert_eq!(area.width, 74.0);
1083    }
1084
1085    #[test]
1086    fn test_graph_area_with_no_margins() {
1087        let mut graph = BrailleGraph::new(vec![1.0, 2.0]).with_margins(AxisMargins::NONE);
1088        graph.bounds = Rect::new(0.0, 0.0, 80.0, 24.0);
1089        let area = graph.graph_area();
1090        assert_eq!(area.x, 0.0);
1091        assert_eq!(area.y, 0.0);
1092        assert_eq!(area.width, 80.0);
1093        assert_eq!(area.height, 24.0);
1094    }
1095
1096    #[test]
1097    fn test_graph_paint_with_y_axis() {
1098        let mut graph =
1099            BrailleGraph::new(vec![0.0, 50.0, 100.0]).with_margins(AxisMargins::STANDARD);
1100        graph.bounds = Rect::new(0.0, 0.0, 80.0, 10.0);
1101        let mut canvas = MockCanvas::new();
1102        graph.paint(&mut canvas);
1103        // Should render y-axis labels (min and max values)
1104        let has_max_label = canvas.texts.iter().any(|(t, _)| t.contains("100"));
1105        let has_min_label = canvas.texts.iter().any(|(t, _)| t.contains("0"));
1106        assert!(has_max_label || has_min_label);
1107    }
1108
1109    #[test]
1110    fn test_graph_paint_with_x_axis_indices() {
1111        let mut graph = BrailleGraph::new(vec![0.0, 50.0, 100.0])
1112            .with_margins(AxisMargins::STANDARD)
1113            .with_time_axis(TimeAxisMode::Indices);
1114        graph.bounds = Rect::new(0.0, 0.0, 80.0, 10.0);
1115        let mut canvas = MockCanvas::new();
1116        graph.paint(&mut canvas);
1117        // Should render x-axis labels with indices
1118        assert!(!canvas.texts.is_empty());
1119    }
1120
1121    #[test]
1122    fn test_graph_paint_with_x_axis_hidden() {
1123        let mut graph = BrailleGraph::new(vec![0.0, 50.0, 100.0])
1124            .with_margins(AxisMargins::STANDARD)
1125            .with_time_axis(TimeAxisMode::Hidden);
1126        graph.bounds = Rect::new(0.0, 0.0, 80.0, 10.0);
1127        let mut canvas = MockCanvas::new();
1128        graph.paint(&mut canvas);
1129        // Should still render but without x-axis labels
1130        assert!(!canvas.texts.is_empty());
1131    }
1132
1133    #[test]
1134    fn test_graph_paint_with_legend() {
1135        let mut graph = BrailleGraph::new(vec![0.0, 100.0])
1136            .with_legend(true)
1137            .with_margins(AxisMargins::STANDARD);
1138        graph.bounds = Rect::new(0.0, 0.0, 80.0, 10.0);
1139        let mut canvas = MockCanvas::new();
1140        graph.paint(&mut canvas);
1141        // Should render legend with braille characters
1142        let has_legend = canvas
1143            .texts
1144            .iter()
1145            .any(|(t, _)| t.contains("⣿") || t.contains("⣀"));
1146        assert!(has_legend);
1147    }
1148
1149    #[test]
1150    fn test_graph_paint_without_legend() {
1151        let mut graph = BrailleGraph::new(vec![0.0, 100.0])
1152            .with_legend(false)
1153            .with_margins(AxisMargins::NONE);
1154        graph.bounds = Rect::new(0.0, 0.0, 80.0, 10.0);
1155        let mut canvas = MockCanvas::new();
1156        graph.paint(&mut canvas);
1157        // Should not render legend
1158        let has_legend = canvas
1159            .texts
1160            .iter()
1161            .any(|(t, _)| t.contains("⣿=") || t.contains("⣀="));
1162        assert!(!has_legend);
1163    }
1164
1165    #[test]
1166    fn test_graph_paint_with_no_y_axis_margin() {
1167        let mut graph = BrailleGraph::new(vec![0.0, 100.0]).with_margins(AxisMargins::NONE);
1168        graph.bounds = Rect::new(0.0, 0.0, 80.0, 10.0);
1169        let mut canvas = MockCanvas::new();
1170        graph.paint(&mut canvas);
1171        // Should render the graph but not y-axis labels at position 0
1172        assert!(!canvas.texts.is_empty());
1173    }
1174
1175    #[test]
1176    fn test_graph_paint_with_gradient_braille() {
1177        let gradient = Gradient::two(Color::BLUE, Color::RED);
1178        let mut graph = BrailleGraph::new(vec![0.0, 50.0, 100.0])
1179            .with_gradient(gradient)
1180            .with_mode(GraphMode::Braille)
1181            .with_margins(AxisMargins::NONE);
1182        graph.bounds = Rect::new(0.0, 0.0, 10.0, 5.0);
1183        let mut canvas = MockCanvas::new();
1184        graph.paint(&mut canvas);
1185        assert!(!canvas.texts.is_empty());
1186    }
1187
1188    #[test]
1189    fn test_graph_paint_with_gradient_block() {
1190        let gradient = Gradient::two(Color::BLUE, Color::RED);
1191        let mut graph = BrailleGraph::new(vec![0.0, 50.0, 100.0])
1192            .with_gradient(gradient)
1193            .with_mode(GraphMode::Block)
1194            .with_margins(AxisMargins::NONE);
1195        graph.bounds = Rect::new(0.0, 0.0, 10.0, 5.0);
1196        let mut canvas = MockCanvas::new();
1197        graph.paint(&mut canvas);
1198        assert!(!canvas.texts.is_empty());
1199    }
1200
1201    #[test]
1202    fn test_graph_paint_with_gradient_tty() {
1203        let gradient = Gradient::two(Color::BLUE, Color::RED);
1204        let mut graph = BrailleGraph::new(vec![0.0, 50.0, 100.0])
1205            .with_gradient(gradient)
1206            .with_mode(GraphMode::Tty)
1207            .with_margins(AxisMargins::NONE);
1208        graph.bounds = Rect::new(0.0, 0.0, 10.0, 5.0);
1209        let mut canvas = MockCanvas::new();
1210        graph.paint(&mut canvas);
1211        assert!(!canvas.texts.is_empty());
1212    }
1213
1214    #[test]
1215    fn test_graph_block_mode_single_point() {
1216        let mut graph = BrailleGraph::new(vec![50.0]).with_mode(GraphMode::Block);
1217        graph.bounds = Rect::new(0.0, 0.0, 5.0, 4.0);
1218        let mut canvas = MockCanvas::new();
1219        graph.paint(&mut canvas);
1220        assert!(!canvas.texts.is_empty());
1221    }
1222
1223    #[test]
1224    fn test_graph_tty_mode_single_point() {
1225        let mut graph = BrailleGraph::new(vec![50.0]).with_mode(GraphMode::Tty);
1226        graph.bounds = Rect::new(0.0, 0.0, 5.0, 4.0);
1227        let mut canvas = MockCanvas::new();
1228        graph.paint(&mut canvas);
1229        assert!(!canvas.texts.is_empty());
1230    }
1231
1232    #[test]
1233    fn test_graph_braille_more_data_than_width() {
1234        let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
1235        let mut graph = BrailleGraph::new(data)
1236            .with_mode(GraphMode::Braille)
1237            .with_margins(AxisMargins::NONE);
1238        graph.bounds = Rect::new(0.0, 0.0, 10.0, 5.0);
1239        let mut canvas = MockCanvas::new();
1240        graph.paint(&mut canvas);
1241        assert!(!canvas.texts.is_empty());
1242    }
1243
1244    #[test]
1245    fn test_graph_block_more_data_than_width() {
1246        let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
1247        let mut graph = BrailleGraph::new(data)
1248            .with_mode(GraphMode::Block)
1249            .with_margins(AxisMargins::NONE);
1250        graph.bounds = Rect::new(0.0, 0.0, 10.0, 5.0);
1251        let mut canvas = MockCanvas::new();
1252        graph.paint(&mut canvas);
1253        assert!(!canvas.texts.is_empty());
1254    }
1255
1256    #[test]
1257    fn test_graph_tty_more_data_than_width() {
1258        let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
1259        let mut graph = BrailleGraph::new(data)
1260            .with_mode(GraphMode::Tty)
1261            .with_margins(AxisMargins::NONE);
1262        graph.bounds = Rect::new(0.0, 0.0, 10.0, 5.0);
1263        let mut canvas = MockCanvas::new();
1264        graph.paint(&mut canvas);
1265        assert!(!canvas.texts.is_empty());
1266    }
1267
1268    #[test]
1269    fn test_graph_small_bounds_clipping() {
1270        // Test with bounds smaller than margins would require
1271        let mut graph = BrailleGraph::new(vec![0.0, 100.0]).with_margins(AxisMargins::WIDE);
1272        graph.bounds = Rect::new(0.0, 0.0, 5.0, 2.0);
1273        let area = graph.graph_area();
1274        // Width should be clamped to 0 since bounds.width (5) - y_axis_width (10) < 0
1275        assert!(area.width >= 0.0);
1276        assert!(area.height >= 0.0);
1277    }
1278
1279    #[test]
1280    fn test_graph_x_axis_single_data_point() {
1281        let mut graph = BrailleGraph::new(vec![50.0])
1282            .with_margins(AxisMargins::STANDARD)
1283            .with_time_axis(TimeAxisMode::Indices);
1284        graph.bounds = Rect::new(0.0, 0.0, 80.0, 10.0);
1285        let mut canvas = MockCanvas::new();
1286        graph.paint(&mut canvas);
1287        // Should handle single data point gracefully
1288        assert!(!canvas.texts.is_empty());
1289    }
1290
1291    #[test]
1292    fn test_graph_mode_debug() {
1293        // Test Debug impl for GraphMode
1294        let mode = GraphMode::Braille;
1295        let debug_str = format!("{:?}", mode);
1296        assert!(debug_str.contains("Braille"));
1297    }
1298
1299    #[test]
1300    fn test_time_axis_mode_debug() {
1301        // Test Debug impl for TimeAxisMode
1302        let mode = TimeAxisMode::Relative { interval_secs: 60 };
1303        let debug_str = format!("{:?}", mode);
1304        assert!(debug_str.contains("Relative"));
1305        assert!(debug_str.contains("60"));
1306    }
1307
1308    #[test]
1309    fn test_axis_margins_debug() {
1310        // Test Debug impl for AxisMargins
1311        let margins = AxisMargins::WIDE;
1312        let debug_str = format!("{:?}", margins);
1313        assert!(debug_str.contains("10")); // y_axis_width
1314        assert!(debug_str.contains("2")); // x_axis_height
1315    }
1316
1317    #[test]
1318    fn test_graph_clone() {
1319        let graph = BrailleGraph::new(vec![1.0, 2.0, 3.0])
1320            .with_color(Color::RED)
1321            .with_label("Test")
1322            .with_range(0.0, 100.0);
1323        let cloned = graph.clone();
1324        assert_eq!(cloned.data, graph.data);
1325        assert_eq!(cloned.color, graph.color);
1326        assert_eq!(cloned.label, graph.label);
1327        assert_eq!(cloned.min, graph.min);
1328        assert_eq!(cloned.max, graph.max);
1329    }
1330
1331    #[test]
1332    fn test_graph_debug() {
1333        let graph = BrailleGraph::new(vec![1.0, 2.0]);
1334        let debug_str = format!("{:?}", graph);
1335        assert!(debug_str.contains("BrailleGraph"));
1336    }
1337}