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::key_down(presentar_core::Key::Enter);
830        assert!(graph.event(&event).is_none());
831    }
832
833    #[test]
834    fn test_graph_children() {
835        let graph = BrailleGraph::new(vec![1.0]);
836        assert!(graph.children().is_empty());
837    }
838
839    #[test]
840    fn test_graph_children_mut() {
841        let mut graph = BrailleGraph::new(vec![1.0]);
842        assert!(graph.children_mut().is_empty());
843    }
844
845    #[test]
846    fn test_graph_type_id() {
847        let graph = BrailleGraph::new(vec![1.0]);
848        assert_eq!(Widget::type_id(&graph), TypeId::of::<BrailleGraph>());
849    }
850
851    #[test]
852    fn test_graph_brick_name() {
853        let graph = BrailleGraph::new(vec![1.0]);
854        assert_eq!(graph.brick_name(), "braille_graph");
855    }
856
857    #[test]
858    fn test_graph_budget() {
859        let graph = BrailleGraph::new(vec![1.0]);
860        let budget = graph.budget();
861        assert!(budget.measure_ms > 0);
862    }
863
864    #[test]
865    fn test_graph_to_html() {
866        let graph = BrailleGraph::new(vec![1.0]);
867        assert!(graph.to_html().is_empty());
868    }
869
870    #[test]
871    fn test_graph_to_css() {
872        let graph = BrailleGraph::new(vec![1.0]);
873        assert!(graph.to_css().is_empty());
874    }
875
876    #[test]
877    fn test_graph_mode_default() {
878        assert_eq!(GraphMode::default(), GraphMode::Braille);
879    }
880
881    #[test]
882    fn test_graph_large_dataset() {
883        let data: Vec<f64> = (0..1000).map(|i| (i as f64).sin()).collect();
884        let mut graph = BrailleGraph::new(data);
885        graph.bounds = Rect::new(0.0, 0.0, 50.0, 10.0);
886        let mut canvas = MockCanvas::new();
887        graph.paint(&mut canvas);
888        assert!(!canvas.texts.is_empty());
889    }
890
891    #[test]
892    fn test_graph_block_mode_various_values() {
893        let mut graph =
894            BrailleGraph::new(vec![0.0, 25.0, 50.0, 75.0, 100.0]).with_mode(GraphMode::Block);
895        graph.bounds = Rect::new(0.0, 0.0, 5.0, 4.0);
896        let mut canvas = MockCanvas::new();
897        graph.paint(&mut canvas);
898        assert!(!canvas.texts.is_empty());
899    }
900
901    #[test]
902    fn test_graph_tty_mode_various_values() {
903        let mut graph =
904            BrailleGraph::new(vec![0.0, 25.0, 50.0, 75.0, 100.0]).with_mode(GraphMode::Tty);
905        graph.bounds = Rect::new(0.0, 0.0, 5.0, 4.0);
906        let mut canvas = MockCanvas::new();
907        graph.paint(&mut canvas);
908        assert!(!canvas.texts.is_empty());
909    }
910
911    // ========================================================================
912    // Additional tests for axis margins and time axis
913    // ========================================================================
914
915    #[test]
916    fn test_graph_with_margins() {
917        let graph = BrailleGraph::new(vec![1.0, 2.0]).with_margins(AxisMargins::WIDE);
918        assert_eq!(graph.margins.y_axis_width, 10);
919        assert_eq!(graph.margins.x_axis_height, 2);
920    }
921
922    #[test]
923    fn test_graph_with_margins_none() {
924        let graph = BrailleGraph::new(vec![1.0, 2.0]).with_margins(AxisMargins::NONE);
925        assert_eq!(graph.margins.y_axis_width, 0);
926        assert_eq!(graph.margins.x_axis_height, 0);
927    }
928
929    #[test]
930    fn test_graph_with_margins_compact() {
931        let graph = BrailleGraph::new(vec![1.0, 2.0]).with_margins(AxisMargins::COMPACT);
932        assert_eq!(graph.margins.y_axis_width, 4);
933        assert_eq!(graph.margins.x_axis_height, 1);
934    }
935
936    #[test]
937    fn test_graph_with_margins_standard() {
938        let graph = BrailleGraph::new(vec![1.0, 2.0]).with_margins(AxisMargins::STANDARD);
939        assert_eq!(graph.margins.y_axis_width, 6);
940        assert_eq!(graph.margins.x_axis_height, 1);
941    }
942
943    #[test]
944    fn test_axis_margins_default() {
945        let margins = AxisMargins::default();
946        assert_eq!(margins.y_axis_width, 6);
947        assert_eq!(margins.x_axis_height, 1);
948    }
949
950    #[test]
951    fn test_graph_with_time_axis_indices() {
952        let graph = BrailleGraph::new(vec![1.0, 2.0]).with_time_axis(TimeAxisMode::Indices);
953        assert_eq!(graph.time_axis, TimeAxisMode::Indices);
954    }
955
956    #[test]
957    fn test_graph_with_time_axis_relative() {
958        let graph = BrailleGraph::new(vec![1.0, 2.0])
959            .with_time_axis(TimeAxisMode::Relative { interval_secs: 5 });
960        match graph.time_axis {
961            TimeAxisMode::Relative { interval_secs } => assert_eq!(interval_secs, 5),
962            _ => panic!("Expected Relative time axis mode"),
963        }
964    }
965
966    #[test]
967    fn test_graph_with_time_axis_absolute() {
968        let graph = BrailleGraph::new(vec![1.0, 2.0]).with_time_axis(TimeAxisMode::Absolute);
969        assert_eq!(graph.time_axis, TimeAxisMode::Absolute);
970    }
971
972    #[test]
973    fn test_graph_with_time_axis_hidden() {
974        let graph = BrailleGraph::new(vec![1.0, 2.0]).with_time_axis(TimeAxisMode::Hidden);
975        assert_eq!(graph.time_axis, TimeAxisMode::Hidden);
976    }
977
978    #[test]
979    fn test_time_axis_mode_default() {
980        assert_eq!(TimeAxisMode::default(), TimeAxisMode::Indices);
981    }
982
983    #[test]
984    fn test_time_axis_format_label_indices() {
985        let mode = TimeAxisMode::Indices;
986        assert_eq!(mode.format_label(0, 10), Some("0".to_string()));
987        assert_eq!(mode.format_label(5, 10), Some("5".to_string()));
988        assert_eq!(mode.format_label(9, 10), Some("9".to_string()));
989    }
990
991    #[test]
992    fn test_time_axis_format_label_relative_seconds() {
993        let mode = TimeAxisMode::Relative { interval_secs: 1 };
994        // At index 0 with total 60, that's 60 seconds ago
995        assert_eq!(mode.format_label(0, 60), Some("1m".to_string()));
996        // At index 59 with total 60, that's 1 second ago
997        assert_eq!(mode.format_label(59, 60), Some("1s".to_string()));
998        // At index 30 with total 60, that's 30 seconds ago
999        assert_eq!(mode.format_label(30, 60), Some("30s".to_string()));
1000    }
1001
1002    #[test]
1003    fn test_time_axis_format_label_relative_minutes() {
1004        let mode = TimeAxisMode::Relative { interval_secs: 60 };
1005        // At index 0 with total 10, that's 600 seconds (10 minutes) ago
1006        assert_eq!(mode.format_label(0, 10), Some("10m".to_string()));
1007        // At index 5 with total 10, that's 300 seconds (5 minutes) ago
1008        assert_eq!(mode.format_label(5, 10), Some("5m".to_string()));
1009    }
1010
1011    #[test]
1012    fn test_time_axis_format_label_relative_hours() {
1013        let mode = TimeAxisMode::Relative {
1014            interval_secs: 3600,
1015        };
1016        // At index 0 with total 5, that's 18000 seconds (5 hours) ago
1017        assert_eq!(mode.format_label(0, 5), Some("5h".to_string()));
1018        // At index 3 with total 5, that's 7200 seconds (2 hours) ago
1019        assert_eq!(mode.format_label(3, 5), Some("2h".to_string()));
1020    }
1021
1022    #[test]
1023    fn test_time_axis_format_label_absolute() {
1024        let mode = TimeAxisMode::Absolute;
1025        assert_eq!(mode.format_label(0, 10), None);
1026    }
1027
1028    #[test]
1029    fn test_time_axis_format_label_hidden() {
1030        let mode = TimeAxisMode::Hidden;
1031        assert_eq!(mode.format_label(0, 10), None);
1032    }
1033
1034    #[test]
1035    fn test_graph_with_legend() {
1036        let graph = BrailleGraph::new(vec![1.0, 2.0]).with_legend(true);
1037        assert!(graph.show_legend);
1038    }
1039
1040    #[test]
1041    fn test_graph_with_legend_disabled() {
1042        let graph = BrailleGraph::new(vec![1.0, 2.0]).with_legend(false);
1043        assert!(!graph.show_legend);
1044    }
1045
1046    #[test]
1047    fn test_graph_with_gradient() {
1048        let gradient = Gradient::two(Color::BLUE, Color::RED);
1049        let graph = BrailleGraph::new(vec![1.0, 2.0]).with_gradient(gradient);
1050        assert!(graph.gradient.is_some());
1051    }
1052
1053    #[test]
1054    fn test_graph_color_for_value_without_gradient() {
1055        let graph = BrailleGraph::new(vec![0.0, 100.0]).with_color(Color::GREEN);
1056        let color = graph.color_for_value(0.5);
1057        assert_eq!(color, Color::GREEN);
1058    }
1059
1060    #[test]
1061    fn test_graph_color_for_value_with_gradient() {
1062        let gradient = Gradient::two(Color::BLUE, Color::RED);
1063        let graph = BrailleGraph::new(vec![0.0, 100.0]).with_gradient(gradient);
1064        // Should get different colors at different positions
1065        let color_low = graph.color_for_value(0.0);
1066        let color_high = graph.color_for_value(1.0);
1067        // Colors should differ (one is blue, one is red)
1068        assert_ne!(color_low, color_high);
1069    }
1070
1071    #[test]
1072    fn test_graph_area_with_margins() {
1073        let mut graph = BrailleGraph::new(vec![1.0, 2.0]).with_margins(AxisMargins::STANDARD);
1074        graph.bounds = Rect::new(0.0, 0.0, 80.0, 24.0);
1075        let area = graph.graph_area();
1076        // y_axis_width = 6, so x starts at 6
1077        assert_eq!(area.x, 6.0);
1078        // x_axis_height = 1, so height reduced by 1
1079        assert_eq!(area.height, 23.0);
1080        assert_eq!(area.width, 74.0);
1081    }
1082
1083    #[test]
1084    fn test_graph_area_with_no_margins() {
1085        let mut graph = BrailleGraph::new(vec![1.0, 2.0]).with_margins(AxisMargins::NONE);
1086        graph.bounds = Rect::new(0.0, 0.0, 80.0, 24.0);
1087        let area = graph.graph_area();
1088        assert_eq!(area.x, 0.0);
1089        assert_eq!(area.y, 0.0);
1090        assert_eq!(area.width, 80.0);
1091        assert_eq!(area.height, 24.0);
1092    }
1093
1094    #[test]
1095    fn test_graph_paint_with_y_axis() {
1096        let mut graph =
1097            BrailleGraph::new(vec![0.0, 50.0, 100.0]).with_margins(AxisMargins::STANDARD);
1098        graph.bounds = Rect::new(0.0, 0.0, 80.0, 10.0);
1099        let mut canvas = MockCanvas::new();
1100        graph.paint(&mut canvas);
1101        // Should render y-axis labels (min and max values)
1102        let has_max_label = canvas.texts.iter().any(|(t, _)| t.contains("100"));
1103        let has_min_label = canvas.texts.iter().any(|(t, _)| t.contains('0'));
1104        assert!(has_max_label || has_min_label);
1105    }
1106
1107    #[test]
1108    fn test_graph_paint_with_x_axis_indices() {
1109        let mut graph = BrailleGraph::new(vec![0.0, 50.0, 100.0])
1110            .with_margins(AxisMargins::STANDARD)
1111            .with_time_axis(TimeAxisMode::Indices);
1112        graph.bounds = Rect::new(0.0, 0.0, 80.0, 10.0);
1113        let mut canvas = MockCanvas::new();
1114        graph.paint(&mut canvas);
1115        // Should render x-axis labels with indices
1116        assert!(!canvas.texts.is_empty());
1117    }
1118
1119    #[test]
1120    fn test_graph_paint_with_x_axis_hidden() {
1121        let mut graph = BrailleGraph::new(vec![0.0, 50.0, 100.0])
1122            .with_margins(AxisMargins::STANDARD)
1123            .with_time_axis(TimeAxisMode::Hidden);
1124        graph.bounds = Rect::new(0.0, 0.0, 80.0, 10.0);
1125        let mut canvas = MockCanvas::new();
1126        graph.paint(&mut canvas);
1127        // Should still render but without x-axis labels
1128        assert!(!canvas.texts.is_empty());
1129    }
1130
1131    #[test]
1132    fn test_graph_paint_with_legend() {
1133        let mut graph = BrailleGraph::new(vec![0.0, 100.0])
1134            .with_legend(true)
1135            .with_margins(AxisMargins::STANDARD);
1136        graph.bounds = Rect::new(0.0, 0.0, 80.0, 10.0);
1137        let mut canvas = MockCanvas::new();
1138        graph.paint(&mut canvas);
1139        // Should render legend with braille characters
1140        let has_legend = canvas
1141            .texts
1142            .iter()
1143            .any(|(t, _)| t.contains("⣿") || t.contains("⣀"));
1144        assert!(has_legend);
1145    }
1146
1147    #[test]
1148    fn test_graph_paint_without_legend() {
1149        let mut graph = BrailleGraph::new(vec![0.0, 100.0])
1150            .with_legend(false)
1151            .with_margins(AxisMargins::NONE);
1152        graph.bounds = Rect::new(0.0, 0.0, 80.0, 10.0);
1153        let mut canvas = MockCanvas::new();
1154        graph.paint(&mut canvas);
1155        // Should not render legend
1156        let has_legend = canvas
1157            .texts
1158            .iter()
1159            .any(|(t, _)| t.contains("⣿=") || t.contains("⣀="));
1160        assert!(!has_legend);
1161    }
1162
1163    #[test]
1164    fn test_graph_paint_with_no_y_axis_margin() {
1165        let mut graph = BrailleGraph::new(vec![0.0, 100.0]).with_margins(AxisMargins::NONE);
1166        graph.bounds = Rect::new(0.0, 0.0, 80.0, 10.0);
1167        let mut canvas = MockCanvas::new();
1168        graph.paint(&mut canvas);
1169        // Should render the graph but not y-axis labels at position 0
1170        assert!(!canvas.texts.is_empty());
1171    }
1172
1173    #[test]
1174    fn test_graph_paint_with_gradient_braille() {
1175        let gradient = Gradient::two(Color::BLUE, Color::RED);
1176        let mut graph = BrailleGraph::new(vec![0.0, 50.0, 100.0])
1177            .with_gradient(gradient)
1178            .with_mode(GraphMode::Braille)
1179            .with_margins(AxisMargins::NONE);
1180        graph.bounds = Rect::new(0.0, 0.0, 10.0, 5.0);
1181        let mut canvas = MockCanvas::new();
1182        graph.paint(&mut canvas);
1183        assert!(!canvas.texts.is_empty());
1184    }
1185
1186    #[test]
1187    fn test_graph_paint_with_gradient_block() {
1188        let gradient = Gradient::two(Color::BLUE, Color::RED);
1189        let mut graph = BrailleGraph::new(vec![0.0, 50.0, 100.0])
1190            .with_gradient(gradient)
1191            .with_mode(GraphMode::Block)
1192            .with_margins(AxisMargins::NONE);
1193        graph.bounds = Rect::new(0.0, 0.0, 10.0, 5.0);
1194        let mut canvas = MockCanvas::new();
1195        graph.paint(&mut canvas);
1196        assert!(!canvas.texts.is_empty());
1197    }
1198
1199    #[test]
1200    fn test_graph_paint_with_gradient_tty() {
1201        let gradient = Gradient::two(Color::BLUE, Color::RED);
1202        let mut graph = BrailleGraph::new(vec![0.0, 50.0, 100.0])
1203            .with_gradient(gradient)
1204            .with_mode(GraphMode::Tty)
1205            .with_margins(AxisMargins::NONE);
1206        graph.bounds = Rect::new(0.0, 0.0, 10.0, 5.0);
1207        let mut canvas = MockCanvas::new();
1208        graph.paint(&mut canvas);
1209        assert!(!canvas.texts.is_empty());
1210    }
1211
1212    #[test]
1213    fn test_graph_block_mode_single_point() {
1214        let mut graph = BrailleGraph::new(vec![50.0]).with_mode(GraphMode::Block);
1215        graph.bounds = Rect::new(0.0, 0.0, 5.0, 4.0);
1216        let mut canvas = MockCanvas::new();
1217        graph.paint(&mut canvas);
1218        assert!(!canvas.texts.is_empty());
1219    }
1220
1221    #[test]
1222    fn test_graph_tty_mode_single_point() {
1223        let mut graph = BrailleGraph::new(vec![50.0]).with_mode(GraphMode::Tty);
1224        graph.bounds = Rect::new(0.0, 0.0, 5.0, 4.0);
1225        let mut canvas = MockCanvas::new();
1226        graph.paint(&mut canvas);
1227        assert!(!canvas.texts.is_empty());
1228    }
1229
1230    #[test]
1231    fn test_graph_braille_more_data_than_width() {
1232        let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
1233        let mut graph = BrailleGraph::new(data)
1234            .with_mode(GraphMode::Braille)
1235            .with_margins(AxisMargins::NONE);
1236        graph.bounds = Rect::new(0.0, 0.0, 10.0, 5.0);
1237        let mut canvas = MockCanvas::new();
1238        graph.paint(&mut canvas);
1239        assert!(!canvas.texts.is_empty());
1240    }
1241
1242    #[test]
1243    fn test_graph_block_more_data_than_width() {
1244        let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
1245        let mut graph = BrailleGraph::new(data)
1246            .with_mode(GraphMode::Block)
1247            .with_margins(AxisMargins::NONE);
1248        graph.bounds = Rect::new(0.0, 0.0, 10.0, 5.0);
1249        let mut canvas = MockCanvas::new();
1250        graph.paint(&mut canvas);
1251        assert!(!canvas.texts.is_empty());
1252    }
1253
1254    #[test]
1255    fn test_graph_tty_more_data_than_width() {
1256        let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
1257        let mut graph = BrailleGraph::new(data)
1258            .with_mode(GraphMode::Tty)
1259            .with_margins(AxisMargins::NONE);
1260        graph.bounds = Rect::new(0.0, 0.0, 10.0, 5.0);
1261        let mut canvas = MockCanvas::new();
1262        graph.paint(&mut canvas);
1263        assert!(!canvas.texts.is_empty());
1264    }
1265
1266    #[test]
1267    fn test_graph_small_bounds_clipping() {
1268        // Test with bounds smaller than margins would require
1269        let mut graph = BrailleGraph::new(vec![0.0, 100.0]).with_margins(AxisMargins::WIDE);
1270        graph.bounds = Rect::new(0.0, 0.0, 5.0, 2.0);
1271        let area = graph.graph_area();
1272        // Width should be clamped to 0 since bounds.width (5) - y_axis_width (10) < 0
1273        assert!(area.width >= 0.0);
1274        assert!(area.height >= 0.0);
1275    }
1276
1277    #[test]
1278    fn test_graph_x_axis_single_data_point() {
1279        let mut graph = BrailleGraph::new(vec![50.0])
1280            .with_margins(AxisMargins::STANDARD)
1281            .with_time_axis(TimeAxisMode::Indices);
1282        graph.bounds = Rect::new(0.0, 0.0, 80.0, 10.0);
1283        let mut canvas = MockCanvas::new();
1284        graph.paint(&mut canvas);
1285        // Should handle single data point gracefully
1286        assert!(!canvas.texts.is_empty());
1287    }
1288
1289    #[test]
1290    fn test_graph_mode_debug() {
1291        // Test Debug impl for GraphMode
1292        let mode = GraphMode::Braille;
1293        let debug_str = format!("{:?}", mode);
1294        assert!(debug_str.contains("Braille"));
1295    }
1296
1297    #[test]
1298    fn test_time_axis_mode_debug() {
1299        // Test Debug impl for TimeAxisMode
1300        let mode = TimeAxisMode::Relative { interval_secs: 60 };
1301        let debug_str = format!("{:?}", mode);
1302        assert!(debug_str.contains("Relative"));
1303        assert!(debug_str.contains("60"));
1304    }
1305
1306    #[test]
1307    fn test_axis_margins_debug() {
1308        // Test Debug impl for AxisMargins
1309        let margins = AxisMargins::WIDE;
1310        let debug_str = format!("{:?}", margins);
1311        assert!(debug_str.contains("10")); // y_axis_width
1312        assert!(debug_str.contains('2')); // x_axis_height
1313    }
1314
1315    #[test]
1316    fn test_graph_clone() {
1317        let graph = BrailleGraph::new(vec![1.0, 2.0, 3.0])
1318            .with_color(Color::RED)
1319            .with_label("Test")
1320            .with_range(0.0, 100.0);
1321        let cloned = graph.clone();
1322        assert_eq!(cloned.data, graph.data);
1323        assert_eq!(cloned.color, graph.color);
1324        assert_eq!(cloned.label, graph.label);
1325        assert_eq!(cloned.min, graph.min);
1326        assert_eq!(cloned.max, graph.max);
1327    }
1328
1329    #[test]
1330    fn test_graph_debug() {
1331        let graph = BrailleGraph::new(vec![1.0, 2.0]);
1332        let debug_str = format!("{:?}", graph);
1333        assert!(debug_str.contains("BrailleGraph"));
1334    }
1335}