mod state;
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Paragraph};
use super::{Component, RenderContext};
use crate::theme::Theme;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub enum UsageLayout {
#[default]
Horizontal,
Vertical,
Grid(usize),
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct UsageMetric {
label: String,
value: String,
color: Option<Color>,
icon: Option<String>,
}
impl UsageMetric {
pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self {
Self {
label: label.into(),
value: value.into(),
color: None,
icon: None,
}
}
pub fn with_color(mut self, color: Color) -> Self {
self.color = Some(color);
self
}
pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
self.icon = Some(icon.into());
self
}
pub fn label(&self) -> &str {
&self.label
}
pub fn value(&self) -> &str {
&self.value
}
pub fn color(&self) -> Option<Color> {
self.color
}
pub fn icon(&self) -> Option<&str> {
self.icon.as_deref()
}
pub fn set_label(&mut self, label: impl Into<String>) {
self.label = label.into();
}
pub fn set_value(&mut self, value: impl Into<String>) {
self.value = value.into();
}
pub fn set_color(&mut self, color: Option<Color>) {
self.color = color;
}
pub fn set_icon(&mut self, icon: Option<String>) {
self.icon = icon;
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum UsageDisplayMessage {
SetMetrics(Vec<UsageMetric>),
AddMetric(UsageMetric),
RemoveMetric(String),
UpdateValue {
label: String,
value: String,
},
UpdateColor {
label: String,
color: Option<Color>,
},
SetLayout(UsageLayout),
SetTitle(Option<String>),
SetSeparator(String),
Clear,
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct UsageDisplayState {
metrics: Vec<UsageMetric>,
layout: UsageLayout,
title: Option<String>,
separator: String,
disabled: bool,
}
impl Default for UsageDisplayState {
fn default() -> Self {
Self {
metrics: Vec::new(),
layout: UsageLayout::default(),
title: None,
separator: " \u{2502} ".to_string(), disabled: false,
}
}
}
pub struct UsageDisplay;
impl UsageDisplay {
fn metric_spans(metric: &UsageMetric, theme: &Theme) -> Vec<Span<'static>> {
let mut spans = Vec::new();
if let Some(icon) = &metric.icon {
spans.push(Span::styled(format!("{} ", icon), theme.normal_style()));
}
spans.push(Span::styled(
format!("{}: ", metric.label),
theme.normal_style(),
));
let value_style = if let Some(color) = metric.color {
Style::default().fg(color)
} else {
theme.normal_style()
};
spans.push(Span::styled(metric.value.clone(), value_style));
spans
}
fn view_horizontal(state: &UsageDisplayState, frame: &mut Frame, area: Rect, theme: &Theme) {
let mut spans = Vec::new();
for (i, metric) in state.metrics.iter().enumerate() {
if i > 0 {
spans.push(Span::styled(
state.separator.clone(),
theme.disabled_style(),
));
}
spans.extend(Self::metric_spans(metric, theme));
}
let line = Line::from(spans);
let paragraph = Paragraph::new(line);
let annotation = crate::annotation::Annotation::new(crate::annotation::WidgetType::Custom(
"UsageDisplay".to_string(),
))
.with_id("usage_display")
.with_meta("metric_count", state.metrics.len().to_string())
.with_meta("layout", "horizontal".to_string());
let annotated = crate::annotation::Annotate::new(paragraph, annotation);
frame.render_widget(annotated, area);
}
fn view_vertical(state: &UsageDisplayState, frame: &mut Frame, area: Rect, theme: &Theme) {
let mut block = Block::default().borders(Borders::ALL);
if let Some(title) = &state.title {
block = block.title(format!(" {} ", title));
}
let inner = block.inner(area);
frame.render_widget(block, area);
let lines: Vec<Line<'static>> = state
.metrics
.iter()
.map(|metric| Line::from(Self::metric_spans(metric, theme)))
.collect();
let paragraph = Paragraph::new(lines);
let annotation = crate::annotation::Annotation::new(crate::annotation::WidgetType::Custom(
"UsageDisplay".to_string(),
))
.with_id("usage_display")
.with_meta("metric_count", state.metrics.len().to_string())
.with_meta("layout", "vertical".to_string());
let annotated = crate::annotation::Annotate::new(paragraph, annotation);
frame.render_widget(annotated, inner);
}
fn view_grid(
state: &UsageDisplayState,
frame: &mut Frame,
area: Rect,
theme: &Theme,
columns: usize,
) {
let columns = columns.max(1);
let mut block = Block::default().borders(Borders::ALL);
if let Some(title) = &state.title {
block = block.title(format!(" {} ", title));
}
let inner = block.inner(area);
frame.render_widget(block, area);
if state.metrics.is_empty() || inner.width == 0 || inner.height == 0 {
return;
}
let col_width = inner.width / columns as u16;
if col_width == 0 {
return;
}
let rows: Vec<&[UsageMetric]> = state.metrics.chunks(columns).collect();
let mut lines: Vec<Line<'static>> = Vec::new();
for row in &rows {
let mut spans: Vec<Span<'static>> = Vec::new();
for (col_idx, metric) in row.iter().enumerate() {
let metric_spans = Self::metric_spans(metric, theme);
let metric_text_len: usize = metric_spans.iter().map(|s| s.content.len()).sum();
spans.extend(metric_spans);
if col_idx < columns - 1 {
let padding = (col_width as usize).saturating_sub(metric_text_len);
if padding > 0 {
spans.push(Span::raw(" ".repeat(padding)));
}
}
}
lines.push(Line::from(spans));
}
let paragraph = Paragraph::new(lines);
let annotation = crate::annotation::Annotation::new(crate::annotation::WidgetType::Custom(
"UsageDisplay".to_string(),
))
.with_id("usage_display")
.with_meta("metric_count", state.metrics.len().to_string())
.with_meta("layout", format!("grid({})", columns));
let annotated = crate::annotation::Annotate::new(paragraph, annotation);
frame.render_widget(annotated, inner);
}
}
impl Component for UsageDisplay {
type State = UsageDisplayState;
type Message = UsageDisplayMessage;
type Output = ();
fn init() -> Self::State {
UsageDisplayState::default()
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
match msg {
UsageDisplayMessage::SetMetrics(metrics) => {
state.metrics = metrics;
}
UsageDisplayMessage::AddMetric(metric) => {
state.metrics.push(metric);
}
UsageDisplayMessage::RemoveMetric(label) => {
state.metrics.retain(|m| m.label != label);
}
UsageDisplayMessage::UpdateValue { label, value } => {
if let Some(metric) = state.metrics.iter_mut().find(|m| m.label == label) {
metric.value = value;
}
}
UsageDisplayMessage::UpdateColor { label, color } => {
if let Some(metric) = state.metrics.iter_mut().find(|m| m.label == label) {
metric.color = color;
}
}
UsageDisplayMessage::SetLayout(layout) => {
state.layout = layout;
}
UsageDisplayMessage::SetTitle(title) => {
state.title = title;
}
UsageDisplayMessage::SetSeparator(separator) => {
state.separator = separator;
}
UsageDisplayMessage::Clear => {
state.metrics.clear();
}
}
None }
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
if state.metrics.is_empty() || ctx.area.width == 0 || ctx.area.height == 0 {
return;
}
match state.layout {
UsageLayout::Horizontal => Self::view_horizontal(state, ctx.frame, ctx.area, ctx.theme),
UsageLayout::Vertical => Self::view_vertical(state, ctx.frame, ctx.area, ctx.theme),
UsageLayout::Grid(cols) => Self::view_grid(state, ctx.frame, ctx.area, ctx.theme, cols),
}
}
}
#[cfg(test)]
mod tests;