pub mod widget;
pub use widget::{MetricKind, MetricWidget};
use std::marker::PhantomData;
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Paragraph, Sparkline};
use super::{Component, Disableable, Focusable, ViewContext};
use crate::input::{Event, KeyCode};
use crate::theme::Theme;
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub enum MetricsDashboardMessage {
Left,
Right,
Up,
Down,
First,
Last,
Select,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
#[non_exhaustive]
pub enum MetricsDashboardOutput {
SelectionChanged(usize),
Selected(usize),
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct MetricsDashboardState {
widgets: Vec<MetricWidget>,
columns: usize,
selected: Option<usize>,
focused: bool,
disabled: bool,
title: Option<String>,
}
impl Default for MetricsDashboardState {
fn default() -> Self {
Self {
widgets: Vec::new(),
columns: 3,
selected: None,
focused: false,
disabled: false,
title: None,
}
}
}
impl MetricsDashboardState {
pub fn new(widgets: Vec<MetricWidget>, columns: usize) -> Self {
let selected = if widgets.is_empty() { None } else { Some(0) };
Self {
widgets,
columns: columns.max(1),
selected,
focused: false,
disabled: false,
title: None,
}
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn with_disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn widgets(&self) -> &[MetricWidget] {
&self.widgets
}
pub fn widgets_mut(&mut self) -> &mut [MetricWidget] {
&mut self.widgets
}
pub fn widget(&self, index: usize) -> Option<&MetricWidget> {
self.widgets.get(index)
}
pub fn widget_mut(&mut self, index: usize) -> Option<&mut MetricWidget> {
self.widgets.get_mut(index)
}
pub fn widget_count(&self) -> usize {
self.widgets.len()
}
pub fn columns(&self) -> usize {
self.columns
}
pub fn set_columns(&mut self, columns: usize) {
self.columns = columns.max(1);
}
pub fn rows(&self) -> usize {
if self.widgets.is_empty() {
0
} else {
self.widgets.len().div_ceil(self.columns)
}
}
pub fn selected_index(&self) -> Option<usize> {
self.selected
}
pub fn selected(&self) -> Option<usize> {
self.selected_index()
}
pub fn set_selected(&mut self, index: Option<usize>) {
match index {
Some(i) => {
if self.widgets.is_empty() {
return;
}
self.selected = Some(i.min(self.widgets.len() - 1));
}
None => self.selected = None,
}
}
pub fn selected_widget(&self) -> Option<&MetricWidget> {
self.widgets.get(self.selected?)
}
pub fn selected_position(&self) -> Option<(usize, usize)> {
let selected = self.selected?;
Some((selected / self.columns, selected % self.columns))
}
pub fn title(&self) -> Option<&str> {
self.title.as_deref()
}
pub fn set_title(&mut self, title: Option<String>) {
self.title = title;
}
pub fn is_empty(&self) -> bool {
self.widgets.is_empty()
}
pub fn is_focused(&self) -> bool {
self.focused
}
pub fn set_focused(&mut self, focused: bool) {
self.focused = focused;
}
pub fn is_disabled(&self) -> bool {
self.disabled
}
pub fn set_disabled(&mut self, disabled: bool) {
self.disabled = disabled;
}
pub fn handle_event(&self, event: &Event) -> Option<MetricsDashboardMessage> {
MetricsDashboard::handle_event(self, event)
}
pub fn dispatch_event(&mut self, event: &Event) -> Option<MetricsDashboardOutput> {
MetricsDashboard::dispatch_event(self, event)
}
pub fn update(&mut self, msg: MetricsDashboardMessage) -> Option<MetricsDashboardOutput> {
MetricsDashboard::update(self, msg)
}
}
pub struct MetricsDashboard(PhantomData<()>);
impl Component for MetricsDashboard {
type State = MetricsDashboardState;
type Message = MetricsDashboardMessage;
type Output = MetricsDashboardOutput;
fn init() -> Self::State {
MetricsDashboardState::default()
}
fn handle_event(state: &Self::State, event: &Event) -> Option<Self::Message> {
if !state.focused || state.disabled {
return None;
}
let key = event.as_key()?;
match key.code {
KeyCode::Left | KeyCode::Char('h') => Some(MetricsDashboardMessage::Left),
KeyCode::Right | KeyCode::Char('l') => Some(MetricsDashboardMessage::Right),
KeyCode::Up | KeyCode::Char('k') => Some(MetricsDashboardMessage::Up),
KeyCode::Down | KeyCode::Char('j') => Some(MetricsDashboardMessage::Down),
KeyCode::Home => Some(MetricsDashboardMessage::First),
KeyCode::End => Some(MetricsDashboardMessage::Last),
KeyCode::Enter => Some(MetricsDashboardMessage::Select),
_ => None,
}
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
if state.disabled || state.widgets.is_empty() {
return None;
}
let len = state.widgets.len();
let cols = state.columns;
let current = state.selected.unwrap_or(0);
let current_row = current / cols;
let current_col = current % cols;
match msg {
MetricsDashboardMessage::Left => {
if current_col > 0 {
let new_index = current - 1;
state.selected = Some(new_index);
Some(MetricsDashboardOutput::SelectionChanged(new_index))
} else {
None
}
}
MetricsDashboardMessage::Right => {
if current_col < cols - 1 && current + 1 < len {
let new_index = current + 1;
state.selected = Some(new_index);
Some(MetricsDashboardOutput::SelectionChanged(new_index))
} else {
None
}
}
MetricsDashboardMessage::Up => {
if current_row > 0 {
let new_index = (current_row - 1) * cols + current_col;
if new_index < len {
state.selected = Some(new_index);
Some(MetricsDashboardOutput::SelectionChanged(new_index))
} else {
None
}
} else {
None
}
}
MetricsDashboardMessage::Down => {
let new_index = (current_row + 1) * cols + current_col;
if new_index < len {
state.selected = Some(new_index);
Some(MetricsDashboardOutput::SelectionChanged(new_index))
} else {
None
}
}
MetricsDashboardMessage::First => {
if current != 0 {
state.selected = Some(0);
Some(MetricsDashboardOutput::SelectionChanged(0))
} else {
None
}
}
MetricsDashboardMessage::Last => {
let last = len - 1;
if current != last {
state.selected = Some(last);
Some(MetricsDashboardOutput::SelectionChanged(last))
} else {
None
}
}
MetricsDashboardMessage::Select => Some(MetricsDashboardOutput::Selected(current)),
}
}
fn view(state: &Self::State, frame: &mut Frame, area: Rect, theme: &Theme, ctx: &ViewContext) {
if state.widgets.is_empty() || area.height < 3 || area.width < 3 {
return;
}
crate::annotation::with_registry(|reg| {
reg.register(
area,
crate::annotation::Annotation::container("metrics_dashboard")
.with_focus(ctx.focused)
.with_disabled(ctx.disabled),
);
});
let rows = state.rows();
let cols = state.columns;
let row_constraints: Vec<Constraint> = (0..rows)
.map(|_| Constraint::Ratio(1, rows as u32))
.collect();
let row_areas = Layout::default()
.direction(Direction::Vertical)
.constraints(row_constraints)
.split(area);
let col_constraints: Vec<Constraint> = (0..cols)
.map(|_| Constraint::Ratio(1, cols as u32))
.collect();
for (row_idx, row_area) in row_areas.iter().enumerate() {
let col_areas = Layout::default()
.direction(Direction::Horizontal)
.constraints(col_constraints.clone())
.split(*row_area);
for (col_idx, col_area) in col_areas.iter().enumerate() {
let widget_idx = row_idx * cols + col_idx;
if let Some(widget) = state.widgets.get(widget_idx) {
let is_selected = state.selected == Some(widget_idx);
render_widget(widget, is_selected, state, frame, *col_area, theme);
}
}
}
}
}
impl Focusable for MetricsDashboard {
fn is_focused(state: &Self::State) -> bool {
state.focused
}
fn set_focused(state: &mut Self::State, focused: bool) {
state.focused = focused;
}
}
impl Disableable for MetricsDashboard {
fn is_disabled(state: &Self::State) -> bool {
state.disabled
}
fn set_disabled(state: &mut Self::State, disabled: bool) {
state.disabled = disabled;
}
}
fn render_widget(
widget: &MetricWidget,
is_selected: bool,
state: &MetricsDashboardState,
frame: &mut Frame,
area: Rect,
theme: &Theme,
) {
let border_style = if state.disabled {
theme.disabled_style()
} else if is_selected && state.focused {
theme.focused_border_style()
} else {
theme.border_style()
};
let block = Block::default()
.title(widget.label())
.borders(Borders::ALL)
.border_style(border_style);
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height == 0 || inner.width == 0 {
return;
}
let value_style = if state.disabled {
theme.disabled_style()
} else {
value_color(widget, theme)
};
if !widget.history.is_empty() && inner.height >= 3 {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(1)])
.split(inner);
let value_text = widget.display_value();
let paragraph = Paragraph::new(value_text).style(value_style);
frame.render_widget(paragraph, chunks[0]);
let sparkline = Sparkline::default()
.data(&widget.history)
.style(value_style);
frame.render_widget(sparkline, chunks[1]);
} else {
let value_text = widget.display_value();
let paragraph = Paragraph::new(value_text)
.style(value_style)
.alignment(Alignment::Center);
frame.render_widget(paragraph, inner);
}
}
fn value_color(widget: &MetricWidget, theme: &Theme) -> Style {
match &widget.kind {
MetricKind::Counter { .. } => theme.info_style(),
MetricKind::Gauge { value, max } => {
let pct = if *max > 0 {
*value as f64 / *max as f64
} else {
0.0
};
if pct >= 0.9 {
theme.error_style()
} else if pct >= 0.7 {
theme.warning_style()
} else {
theme.success_style()
}
}
MetricKind::Status { up } => {
if *up {
theme.success_style()
} else {
theme.error_style()
}
}
MetricKind::Text { .. } => theme.normal_style(),
}
}
#[cfg(test)]
mod snapshot_tests;
#[cfg(test)]
mod tests;