Skip to main content

envision/component/metrics_dashboard/
mod.rs

1//! A configurable dashboard of metric widgets.
2//!
3//! `MetricsDashboard` displays a grid of metric widgets, each showing a
4//! labeled value with an optional sparkline history. Supports keyboard
5//! navigation between widgets and tick-based value updates.
6//!
7//! # Example
8//!
9//! ```rust
10//! use envision::component::{
11//!     Component, Focusable, MetricsDashboard, MetricsDashboardState,
12//!     MetricsDashboardMessage, MetricWidget, MetricKind,
13//! };
14//!
15//! let mut state = MetricsDashboardState::new(vec![
16//!     MetricWidget::counter("Requests", 0),
17//!     MetricWidget::gauge("CPU %", 0, 100),
18//!     MetricWidget::status("API", true),
19//! ], 3);
20//!
21//! assert_eq!(state.widget_count(), 3);
22//! assert_eq!(state.columns(), 3);
23//! assert_eq!(state.selected_index(), Some(0));
24//! ```
25
26use std::marker::PhantomData;
27
28use ratatui::prelude::*;
29use ratatui::widgets::{Block, Borders, Paragraph, Sparkline};
30
31use super::{Component, Focusable};
32use crate::input::{Event, KeyCode};
33use crate::theme::Theme;
34
35/// The kind of metric a widget displays.
36#[derive(Clone, Debug, PartialEq)]
37pub enum MetricKind {
38    /// A numeric counter value.
39    Counter {
40        /// The current value.
41        value: i64,
42    },
43    /// A gauge value with a known range.
44    Gauge {
45        /// The current value.
46        value: u64,
47        /// The maximum value.
48        max: u64,
49    },
50    /// A status indicator (up/down).
51    Status {
52        /// Whether the status is "up" (healthy).
53        up: bool,
54    },
55    /// A text-based metric.
56    Text {
57        /// The display text.
58        text: String,
59    },
60}
61
62/// A single metric widget in the dashboard.
63#[derive(Clone, Debug, PartialEq)]
64pub struct MetricWidget {
65    /// The display label.
66    label: String,
67    /// The metric kind and value.
68    kind: MetricKind,
69    /// Sparkline history (recent values for trend display).
70    history: Vec<u64>,
71    /// Maximum history length.
72    max_history: usize,
73}
74
75impl MetricWidget {
76    /// Creates a counter widget.
77    ///
78    /// # Example
79    ///
80    /// ```rust
81    /// use envision::component::MetricWidget;
82    ///
83    /// let widget = MetricWidget::counter("Requests", 42);
84    /// assert_eq!(widget.label(), "Requests");
85    /// assert_eq!(widget.display_value(), "42");
86    /// ```
87    pub fn counter(label: impl Into<String>, value: i64) -> Self {
88        Self {
89            label: label.into(),
90            kind: MetricKind::Counter { value },
91            history: Vec::new(),
92            max_history: 20,
93        }
94    }
95
96    /// Creates a gauge widget with a maximum value.
97    ///
98    /// # Example
99    ///
100    /// ```rust
101    /// use envision::component::MetricWidget;
102    ///
103    /// let widget = MetricWidget::gauge("CPU %", 75, 100);
104    /// assert_eq!(widget.display_value(), "75/100");
105    /// ```
106    pub fn gauge(label: impl Into<String>, value: u64, max: u64) -> Self {
107        Self {
108            label: label.into(),
109            kind: MetricKind::Gauge { value, max },
110            history: Vec::new(),
111            max_history: 20,
112        }
113    }
114
115    /// Creates a status indicator widget.
116    pub fn status(label: impl Into<String>, up: bool) -> Self {
117        Self {
118            label: label.into(),
119            kind: MetricKind::Status { up },
120            history: Vec::new(),
121            max_history: 0,
122        }
123    }
124
125    /// Creates a text metric widget.
126    pub fn text(label: impl Into<String>, text: impl Into<String>) -> Self {
127        Self {
128            label: label.into(),
129            kind: MetricKind::Text { text: text.into() },
130            history: Vec::new(),
131            max_history: 0,
132        }
133    }
134
135    /// Sets the maximum history length for sparkline display (builder pattern).
136    pub fn with_max_history(mut self, max: usize) -> Self {
137        self.max_history = max;
138        self
139    }
140
141    /// Returns the label.
142    pub fn label(&self) -> &str {
143        &self.label
144    }
145
146    /// Returns the metric kind.
147    pub fn kind(&self) -> &MetricKind {
148        &self.kind
149    }
150
151    /// Returns the sparkline history.
152    pub fn history(&self) -> &[u64] {
153        &self.history
154    }
155
156    /// Returns the display value as a string.
157    pub fn display_value(&self) -> String {
158        match &self.kind {
159            MetricKind::Counter { value } => value.to_string(),
160            MetricKind::Gauge { value, max } => format!("{}/{}", value, max),
161            MetricKind::Status { up } => {
162                if *up {
163                    "UP".to_string()
164                } else {
165                    "DOWN".to_string()
166                }
167            }
168            MetricKind::Text { text } => text.clone(),
169        }
170    }
171
172    /// Sets the counter value.
173    pub fn set_counter_value(&mut self, value: i64) {
174        if let MetricKind::Counter { value: ref mut v } = self.kind {
175            *v = value;
176            if self.max_history > 0 {
177                self.history.push(value.unsigned_abs());
178                while self.history.len() > self.max_history {
179                    self.history.remove(0);
180                }
181            }
182        }
183    }
184
185    /// Sets the gauge value.
186    pub fn set_gauge_value(&mut self, value: u64) {
187        if let MetricKind::Gauge {
188            value: ref mut v,
189            max,
190        } = self.kind
191        {
192            *v = value.min(max);
193            if self.max_history > 0 {
194                self.history.push(value);
195                while self.history.len() > self.max_history {
196                    self.history.remove(0);
197                }
198            }
199        }
200    }
201
202    /// Sets the status.
203    pub fn set_status(&mut self, up: bool) {
204        if let MetricKind::Status { up: ref mut u } = self.kind {
205            *u = up;
206        }
207    }
208
209    /// Sets the text value.
210    pub fn set_text(&mut self, text: impl Into<String>) {
211        if let MetricKind::Text { text: ref mut t } = self.kind {
212            *t = text.into();
213        }
214    }
215
216    /// Increments a counter by the given amount.
217    pub fn increment(&mut self, amount: i64) {
218        if let MetricKind::Counter { ref mut value } = self.kind {
219            *value += amount;
220            if self.max_history > 0 {
221                self.history.push(value.unsigned_abs());
222                while self.history.len() > self.max_history {
223                    self.history.remove(0);
224                }
225            }
226        }
227    }
228
229    /// Returns the gauge fill percentage (0.0 to 1.0).
230    pub fn gauge_percentage(&self) -> Option<f64> {
231        match &self.kind {
232            MetricKind::Gauge { value, max } if *max > 0 => Some(*value as f64 / *max as f64),
233            _ => None,
234        }
235    }
236}
237
238/// Messages that can be sent to a MetricsDashboard.
239#[derive(Clone, Debug, PartialEq, Eq)]
240pub enum MetricsDashboardMessage {
241    /// Move selection left.
242    Left,
243    /// Move selection right.
244    Right,
245    /// Move selection up.
246    Up,
247    /// Move selection down.
248    Down,
249    /// Move to the first widget.
250    First,
251    /// Move to the last widget.
252    Last,
253    /// Select the current widget (emit output).
254    Select,
255}
256
257/// Output messages from a MetricsDashboard.
258#[derive(Clone, Debug, PartialEq, Eq)]
259pub enum MetricsDashboardOutput {
260    /// The selected widget changed.
261    SelectionChanged(usize),
262    /// A widget was activated (Enter pressed).
263    Selected(usize),
264}
265
266/// State for a MetricsDashboard component.
267///
268/// Contains the grid of widgets and navigation state.
269#[derive(Clone, Debug, PartialEq)]
270pub struct MetricsDashboardState {
271    /// The metric widgets.
272    widgets: Vec<MetricWidget>,
273    /// Number of columns in the grid layout.
274    columns: usize,
275    /// Currently selected widget index.
276    selected: Option<usize>,
277    /// Whether the component is focused.
278    focused: bool,
279    /// Whether the component is disabled.
280    disabled: bool,
281    /// Optional title.
282    title: Option<String>,
283}
284
285impl Default for MetricsDashboardState {
286    fn default() -> Self {
287        Self {
288            widgets: Vec::new(),
289            columns: 3,
290            selected: None,
291            focused: false,
292            disabled: false,
293            title: None,
294        }
295    }
296}
297
298impl MetricsDashboardState {
299    /// Creates a new dashboard with the given widgets and column count.
300    ///
301    /// # Example
302    ///
303    /// ```rust
304    /// use envision::component::{MetricsDashboardState, MetricWidget};
305    ///
306    /// let state = MetricsDashboardState::new(vec![
307    ///     MetricWidget::counter("Items", 0),
308    ///     MetricWidget::gauge("Memory", 512, 1024),
309    /// ], 2);
310    /// assert_eq!(state.widget_count(), 2);
311    /// assert_eq!(state.columns(), 2);
312    /// ```
313    pub fn new(widgets: Vec<MetricWidget>, columns: usize) -> Self {
314        let selected = if widgets.is_empty() { None } else { Some(0) };
315        Self {
316            widgets,
317            columns: columns.max(1),
318            selected,
319            focused: false,
320            disabled: false,
321            title: None,
322        }
323    }
324
325    /// Sets the title (builder pattern).
326    pub fn with_title(mut self, title: impl Into<String>) -> Self {
327        self.title = Some(title.into());
328        self
329    }
330
331    /// Sets the disabled state (builder pattern).
332    pub fn with_disabled(mut self, disabled: bool) -> Self {
333        self.disabled = disabled;
334        self
335    }
336
337    // ---- Accessors ----
338
339    /// Returns the widgets.
340    pub fn widgets(&self) -> &[MetricWidget] {
341        &self.widgets
342    }
343
344    /// Returns a mutable reference to the widgets.
345    pub fn widgets_mut(&mut self) -> &mut [MetricWidget] {
346        &mut self.widgets
347    }
348
349    /// Returns a reference to the widget at the given index.
350    pub fn widget(&self, index: usize) -> Option<&MetricWidget> {
351        self.widgets.get(index)
352    }
353
354    /// Returns a mutable reference to the widget at the given index.
355    pub fn widget_mut(&mut self, index: usize) -> Option<&mut MetricWidget> {
356        self.widgets.get_mut(index)
357    }
358
359    /// Returns the number of widgets.
360    pub fn widget_count(&self) -> usize {
361        self.widgets.len()
362    }
363
364    /// Returns the number of columns.
365    pub fn columns(&self) -> usize {
366        self.columns
367    }
368
369    /// Sets the number of columns.
370    pub fn set_columns(&mut self, columns: usize) {
371        self.columns = columns.max(1);
372    }
373
374    /// Returns the number of rows (based on widget count and columns).
375    pub fn rows(&self) -> usize {
376        if self.widgets.is_empty() {
377            0
378        } else {
379            self.widgets.len().div_ceil(self.columns)
380        }
381    }
382
383    /// Returns the selected widget index.
384    pub fn selected_index(&self) -> Option<usize> {
385        self.selected
386    }
387
388    /// Sets the selected widget index.
389    ///
390    /// The index is clamped to the valid range. Has no effect on empty dashboards.
391    ///
392    /// # Example
393    ///
394    /// ```rust
395    /// use envision::component::{MetricsDashboardState, MetricWidget};
396    ///
397    /// let mut state = MetricsDashboardState::new(vec![
398    ///     MetricWidget::counter("A", 0),
399    ///     MetricWidget::counter("B", 0),
400    ///     MetricWidget::counter("C", 0),
401    /// ], 3);
402    /// state.set_selected(2);
403    /// assert_eq!(state.selected_index(), Some(2));
404    /// ```
405    pub fn set_selected(&mut self, index: usize) {
406        if self.widgets.is_empty() {
407            return;
408        }
409        self.selected = Some(index.min(self.widgets.len() - 1));
410    }
411
412    /// Returns a reference to the selected widget.
413    pub fn selected_widget(&self) -> Option<&MetricWidget> {
414        self.widgets.get(self.selected?)
415    }
416
417    /// Returns the (row, column) position of the selected widget.
418    pub fn selected_position(&self) -> Option<(usize, usize)> {
419        let selected = self.selected?;
420        Some((selected / self.columns, selected % self.columns))
421    }
422
423    /// Returns the title.
424    pub fn title(&self) -> Option<&str> {
425        self.title.as_deref()
426    }
427
428    /// Sets the title.
429    pub fn set_title(&mut self, title: Option<String>) {
430        self.title = title;
431    }
432
433    /// Returns true if the dashboard has no widgets.
434    pub fn is_empty(&self) -> bool {
435        self.widgets.is_empty()
436    }
437
438    // ---- Instance methods ----
439
440    /// Returns true if the component is focused.
441    pub fn is_focused(&self) -> bool {
442        self.focused
443    }
444
445    /// Sets the focus state.
446    pub fn set_focused(&mut self, focused: bool) {
447        self.focused = focused;
448    }
449
450    /// Returns true if the component is disabled.
451    pub fn is_disabled(&self) -> bool {
452        self.disabled
453    }
454
455    /// Sets the disabled state.
456    pub fn set_disabled(&mut self, disabled: bool) {
457        self.disabled = disabled;
458    }
459
460    /// Maps an input event to a dashboard message.
461    pub fn handle_event(&self, event: &Event) -> Option<MetricsDashboardMessage> {
462        MetricsDashboard::handle_event(self, event)
463    }
464
465    /// Dispatches an event, updating state and returning any output.
466    pub fn dispatch_event(&mut self, event: &Event) -> Option<MetricsDashboardOutput> {
467        MetricsDashboard::dispatch_event(self, event)
468    }
469
470    /// Updates the state with a message, returning any output.
471    pub fn update(&mut self, msg: MetricsDashboardMessage) -> Option<MetricsDashboardOutput> {
472        MetricsDashboard::update(self, msg)
473    }
474}
475
476/// A configurable dashboard of metric widgets.
477///
478/// Displays widgets in a grid layout with keyboard navigation.
479///
480/// # Key Bindings
481///
482/// - `Left` / `h` — Move selection left
483/// - `Right` / `l` — Move selection right
484/// - `Up` / `k` — Move selection up
485/// - `Down` / `j` — Move selection down
486/// - `Home` — Select first widget
487/// - `End` — Select last widget
488/// - `Enter` — Select current widget
489pub struct MetricsDashboard(PhantomData<()>);
490
491impl Component for MetricsDashboard {
492    type State = MetricsDashboardState;
493    type Message = MetricsDashboardMessage;
494    type Output = MetricsDashboardOutput;
495
496    fn init() -> Self::State {
497        MetricsDashboardState::default()
498    }
499
500    fn handle_event(state: &Self::State, event: &Event) -> Option<Self::Message> {
501        if !state.focused || state.disabled {
502            return None;
503        }
504
505        let key = event.as_key()?;
506
507        match key.code {
508            KeyCode::Left | KeyCode::Char('h') => Some(MetricsDashboardMessage::Left),
509            KeyCode::Right | KeyCode::Char('l') => Some(MetricsDashboardMessage::Right),
510            KeyCode::Up | KeyCode::Char('k') => Some(MetricsDashboardMessage::Up),
511            KeyCode::Down | KeyCode::Char('j') => Some(MetricsDashboardMessage::Down),
512            KeyCode::Home => Some(MetricsDashboardMessage::First),
513            KeyCode::End => Some(MetricsDashboardMessage::Last),
514            KeyCode::Enter => Some(MetricsDashboardMessage::Select),
515            _ => None,
516        }
517    }
518
519    fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
520        if state.disabled || state.widgets.is_empty() {
521            return None;
522        }
523
524        let len = state.widgets.len();
525        let cols = state.columns;
526        let current = state.selected.unwrap_or(0);
527        let current_row = current / cols;
528        let current_col = current % cols;
529
530        match msg {
531            MetricsDashboardMessage::Left => {
532                if current_col > 0 {
533                    let new_index = current - 1;
534                    state.selected = Some(new_index);
535                    Some(MetricsDashboardOutput::SelectionChanged(new_index))
536                } else {
537                    None
538                }
539            }
540            MetricsDashboardMessage::Right => {
541                if current_col < cols - 1 && current + 1 < len {
542                    let new_index = current + 1;
543                    state.selected = Some(new_index);
544                    Some(MetricsDashboardOutput::SelectionChanged(new_index))
545                } else {
546                    None
547                }
548            }
549            MetricsDashboardMessage::Up => {
550                if current_row > 0 {
551                    let new_index = (current_row - 1) * cols + current_col;
552                    if new_index < len {
553                        state.selected = Some(new_index);
554                        Some(MetricsDashboardOutput::SelectionChanged(new_index))
555                    } else {
556                        None
557                    }
558                } else {
559                    None
560                }
561            }
562            MetricsDashboardMessage::Down => {
563                let new_index = (current_row + 1) * cols + current_col;
564                if new_index < len {
565                    state.selected = Some(new_index);
566                    Some(MetricsDashboardOutput::SelectionChanged(new_index))
567                } else {
568                    None
569                }
570            }
571            MetricsDashboardMessage::First => {
572                if current != 0 {
573                    state.selected = Some(0);
574                    Some(MetricsDashboardOutput::SelectionChanged(0))
575                } else {
576                    None
577                }
578            }
579            MetricsDashboardMessage::Last => {
580                let last = len - 1;
581                if current != last {
582                    state.selected = Some(last);
583                    Some(MetricsDashboardOutput::SelectionChanged(last))
584                } else {
585                    None
586                }
587            }
588            MetricsDashboardMessage::Select => Some(MetricsDashboardOutput::Selected(current)),
589        }
590    }
591
592    fn view(state: &Self::State, frame: &mut Frame, area: Rect, theme: &Theme) {
593        if state.widgets.is_empty() || area.height < 3 || area.width < 3 {
594            return;
595        }
596
597        let rows = state.rows();
598        let cols = state.columns;
599
600        // Compute row heights
601        let row_constraints: Vec<Constraint> = (0..rows)
602            .map(|_| Constraint::Ratio(1, rows as u32))
603            .collect();
604
605        let row_areas = Layout::default()
606            .direction(Direction::Vertical)
607            .constraints(row_constraints)
608            .split(area);
609
610        // Compute column widths
611        let col_constraints: Vec<Constraint> = (0..cols)
612            .map(|_| Constraint::Ratio(1, cols as u32))
613            .collect();
614
615        for (row_idx, row_area) in row_areas.iter().enumerate() {
616            let col_areas = Layout::default()
617                .direction(Direction::Horizontal)
618                .constraints(col_constraints.clone())
619                .split(*row_area);
620
621            for (col_idx, col_area) in col_areas.iter().enumerate() {
622                let widget_idx = row_idx * cols + col_idx;
623                if let Some(widget) = state.widgets.get(widget_idx) {
624                    let is_selected = state.selected == Some(widget_idx);
625                    render_widget(widget, is_selected, state, frame, *col_area, theme);
626                }
627            }
628        }
629    }
630}
631
632impl Focusable for MetricsDashboard {
633    fn is_focused(state: &Self::State) -> bool {
634        state.focused
635    }
636
637    fn set_focused(state: &mut Self::State, focused: bool) {
638        state.focused = focused;
639    }
640}
641
642/// Renders a single metric widget.
643fn render_widget(
644    widget: &MetricWidget,
645    is_selected: bool,
646    state: &MetricsDashboardState,
647    frame: &mut Frame,
648    area: Rect,
649    theme: &Theme,
650) {
651    let border_style = if state.disabled {
652        theme.disabled_style()
653    } else if is_selected && state.focused {
654        theme.focused_border_style()
655    } else {
656        theme.border_style()
657    };
658
659    let block = Block::default()
660        .title(widget.label())
661        .borders(Borders::ALL)
662        .border_style(border_style);
663
664    let inner = block.inner(area);
665    frame.render_widget(block, area);
666
667    if inner.height == 0 || inner.width == 0 {
668        return;
669    }
670
671    let value_style = if state.disabled {
672        theme.disabled_style()
673    } else {
674        value_color(widget, theme)
675    };
676
677    // Show sparkline if there's history and enough space
678    if !widget.history.is_empty() && inner.height >= 3 {
679        let chunks = Layout::default()
680            .direction(Direction::Vertical)
681            .constraints([Constraint::Length(1), Constraint::Min(1)])
682            .split(inner);
683
684        // Value line
685        let value_text = widget.display_value();
686        let paragraph = Paragraph::new(value_text).style(value_style);
687        frame.render_widget(paragraph, chunks[0]);
688
689        // Sparkline
690        let sparkline = Sparkline::default()
691            .data(&widget.history)
692            .style(value_style);
693        frame.render_widget(sparkline, chunks[1]);
694    } else {
695        // Just value
696        let value_text = widget.display_value();
697        let paragraph = Paragraph::new(value_text)
698            .style(value_style)
699            .alignment(Alignment::Center);
700        frame.render_widget(paragraph, inner);
701    }
702}
703
704/// Returns the appropriate style for a widget's value.
705fn value_color(widget: &MetricWidget, theme: &Theme) -> Style {
706    match &widget.kind {
707        MetricKind::Counter { .. } => theme.info_style(),
708        MetricKind::Gauge { value, max } => {
709            let pct = if *max > 0 {
710                *value as f64 / *max as f64
711            } else {
712                0.0
713            };
714            if pct >= 0.9 {
715                theme.error_style()
716            } else if pct >= 0.7 {
717                theme.warning_style()
718            } else {
719                theme.success_style()
720            }
721        }
722        MetricKind::Status { up } => {
723            if *up {
724                theme.success_style()
725            } else {
726                theme.error_style()
727            }
728        }
729        MetricKind::Text { .. } => theme.normal_style(),
730    }
731}
732
733#[cfg(test)]
734mod tests;