use super::super::render_scroll_view_persistent_state;
use super::{ModalHeight, ModalSpec, draw_modal_panel, modal_text_area};
use crate::tui::{
state::MissionControlState,
theme::MATRIX_GREEN,
usage::{
UsageLoadResult, UsageProviderRow, UsageWindowRow, format_percent_basis_points,
usage_text_content,
},
};
use ratatui::{
Frame,
layout::{Rect, Size},
text::{Line, Span},
widgets::{Gauge, Paragraph, Wrap},
};
use tui_scrollview::{ScrollView, ScrollbarVisibility};
const USAGE_MODAL: ModalSpec = ModalSpec {
min_width: 24,
min_height: 8,
width_ratio: (9, 10),
height: ModalHeight::Ratio(4, 5),
};
#[derive(Debug, Clone)]
enum UsageRenderRow {
Text(Line<'static>),
Gauge(UsageGaugeRow),
}
#[derive(Debug, Clone)]
struct UsageGaugeRow {
label: String,
basis_points: u16,
}
pub(crate) fn usage_modal_text_area(area: Rect) -> Option<Rect> {
let text_area = modal_text_area(area, "Usage", USAGE_MODAL)?;
body_area(text_area)
}
pub(in crate::tui::render) fn draw_usage_modal(
frame: &mut Frame<'_>,
area: Rect,
state: &MissionControlState,
) {
let Some(modal_state) = &state.modals.usage_modal else {
return;
};
let Some(text_area) = draw_modal_panel(frame, area, "Usage", USAGE_MODAL) else {
return;
};
if text_area.height == 0 {
return;
}
let Some(body) = body_area(text_area) else {
return;
};
frame.render_widget(
Paragraph::new(super::super::hotkey_line(
"[Esc]/[q] close • [Up]/[Down] scroll • [PgUp]/[PgDn] page",
MATRIX_GREEN.muted(false),
))
.style(MATRIX_GREEN.muted(false)),
Rect::new(text_area.x, text_area.y, text_area.width, 1),
);
if let Some(text) = usage_text_content(modal_state.loading, modal_state.result.as_ref()) {
let wrap_width = super::super::scroll_view_content_width(body).max(1);
render_scroll_view_persistent_state(
frame,
body,
super::super::usize_to_u16_saturating(
crate::tui::transcript::ratatui_wrapped_visual_rows(&text, wrap_width),
),
Paragraph::new(text)
.style(MATRIX_GREEN.pane())
.wrap(Wrap { trim: false }),
&state.scroll_views.usage_modal,
);
return;
}
let rows = usage_rows(modal_state.result.as_ref());
let content_height =
super::super::usize_to_u16_saturating(rows.len().max(usize::from(body.height)));
let content_width = super::super::scroll_view_content_width(body);
let mut view = ScrollView::new(Size::new(content_width, content_height))
.vertical_scrollbar_visibility(ScrollbarVisibility::Always)
.horizontal_scrollbar_visibility(ScrollbarVisibility::Never);
for (offset, row) in rows.into_iter().enumerate() {
let row_area = Rect::new(
0,
super::super::usize_to_u16_saturating(offset),
content_width,
1,
);
match row {
UsageRenderRow::Text(line) => {
view.render_widget(Paragraph::new(line).style(MATRIX_GREEN.pane()), row_area)
}
UsageRenderRow::Gauge(gauge) => {
let ratio = f64::from(gauge.basis_points.min(10_000)) / 10_000.0;
let style = if gauge.basis_points >= 5_000 {
MATRIX_GREEN.pane()
} else if gauge.basis_points >= 2_000 {
MATRIX_GREEN.muted(false)
} else {
MATRIX_GREEN.transcript_error_body()
};
view.render_widget(
Gauge::default()
.gauge_style(style)
.label(gauge.label)
.ratio(ratio),
row_area,
);
}
}
}
frame.render_stateful_widget(
view,
body,
&mut *state.scroll_views.usage_modal.borrow_mut(),
);
}
fn body_area(text_area: Rect) -> Option<Rect> {
let y = text_area.y.saturating_add(1);
let height = text_area
.y
.saturating_add(text_area.height)
.saturating_sub(y);
(height > 0).then_some(Rect::new(text_area.x, y, text_area.width, height))
}
fn usage_rows(result: Option<&UsageLoadResult>) -> Vec<UsageRenderRow> {
let Some(UsageLoadResult::Loaded(report)) = result else {
return Vec::new();
};
let mut rows = Vec::new();
if let Some(generated_at) = &report.generated_at {
rows.push(text_row(format!("Generated: {generated_at}")));
}
if let Some(warning) = &report.warning {
rows.push(text_row(format!("Warning: {warning}")));
}
for provider in &report.providers {
rows.push(provider_row(provider));
for window in &provider.windows {
rows.push(window_text_row(window));
rows.push(UsageRenderRow::Gauge(UsageGaugeRow {
label: format!(
"{} remaining",
format_percent_basis_points(window.percent_remaining_basis_points)
),
basis_points: window.percent_remaining_basis_points,
}));
}
rows.push(text_row(""));
}
rows
}
fn text_row(text: impl Into<String>) -> UsageRenderRow {
UsageRenderRow::Text(Line::from(Span::raw(text.into())))
}
fn provider_row(provider: &UsageProviderRow) -> UsageRenderRow {
let mut parts = vec![provider.label.clone()];
if let Some(plan) = &provider.plan {
parts.push(format!("plan:{plan}"));
}
parts.push(format!("source:{}", provider.source));
parts.push(format!("status:{}", provider.status));
text_row(parts.join(" "))
}
fn window_text_row(window: &UsageWindowRow) -> UsageRenderRow {
let reset = window
.reset_text
.as_deref()
.or(window.resets_at.as_deref())
.unwrap_or("unknown");
let money = match (&window.spent_usd, &window.limit_usd) {
(Some(spent), Some(limit)) => format!(" {spent}/{limit}"),
(Some(spent), None) => format!(" spent {spent}"),
(None, Some(limit)) => format!(" limit {limit}"),
(None, None) => String::new(),
};
text_row(format!(
"{} ({}) {} remaining resets {}{}",
window.label,
window.kind,
format_percent_basis_points(window.percent_remaining_basis_points),
reset,
money
))
}