use ratatui::{
layout::{Constraint, Layout, Rect},
style::{Color, Style, Stylize},
text::{Line, Span, Text},
widgets::{Block, Borders, Cell, Paragraph, Row, Table},
Frame,
};
use std::collections::HashMap;
use chrono::Datelike;
use crate::data::models::ModelTokenDetail;
use crate::i18n;
use crate::utils::format_tokens;
pub const COLOR_ACCENT: Color = Color::Cyan;
pub const COLOR_HEADER: Color = Color::Yellow;
pub const COLOR_CARD_BORDER: Color = Color::DarkGray;
pub const COLOR_TABLE_ALT: Color = Color::Reset;
pub const COLOR_MODEL_COLORS: &[Color] = &[
Color::Cyan,
Color::Green,
Color::Magenta,
Color::Yellow,
Color::Red,
Color::Blue,
];
pub fn render_summary_card(frame: &mut Frame, area: Rect, title: &str, value: &str, unit: &str) {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(COLOR_CARD_BORDER))
.title(Span::styled(title, Style::default().fg(COLOR_HEADER)));
let inner = block.inner(area);
frame.render_widget(block, area);
let value_span = Span::styled(
value.to_string(),
Style::default().fg(COLOR_ACCENT).bold(),
);
let unit_span = Span::styled(
format!(" {}", unit),
Style::default().fg(Color::Gray),
);
let text = if inner.height >= 2 {
let v_offset = (inner.height.saturating_sub(2)) / 2;
let mut lines = vec![Line::from(""); v_offset as usize];
lines.push(Line::from(vec![value_span, unit_span]));
Text::from(lines)
} else {
Text::from(Line::from(vec![value_span, unit_span]))
};
let paragraph = Paragraph::new(text).centered();
frame.render_widget(paragraph, inner);
}
pub fn render_summary_row(frame: &mut Frame, area: Rect, cards: &[(&str, &str, &str)]) {
if cards.is_empty() {
return;
}
let constraints: Vec<Constraint> = cards
.iter()
.map(|_| Constraint::Ratio(1, cards.len() as u32))
.collect();
let layout = Layout::horizontal(&constraints).split(area);
for (i, (title, value, unit)) in cards.iter().enumerate() {
if i < layout.len() {
render_summary_card(frame, layout[i], title, value, unit);
}
}
}
pub fn render_percentage_bars(
frame: &mut Frame,
area: Rect,
title: &str,
items: &[(String, f64)],
scroll_offset: u16,
is_focused: bool,
) {
let t = i18n::t();
let border_color = if is_focused { COLOR_ACCENT } else { COLOR_CARD_BORDER };
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color));
let inner = block.inner(area);
frame.render_widget(block, area);
if items.is_empty() {
frame.render_widget(Paragraph::new(t.msg_scanning).centered().fg(Color::Gray), inner);
return;
}
let max_label_width = items.iter().map(|(l, _)| l.len()).max().unwrap_or(10).min(14);
let avail_lines = (inner.height as usize).saturating_sub(2);
let bar_area_width = inner.width.saturating_sub(max_label_width as u16 + 10).max(5) as usize;
let start = scroll_offset as usize;
let mut lines: Vec<Line> = Vec::new();
for (i, (label, pct)) in items.iter().enumerate().skip(start) {
if lines.len() >= avail_lines {
break;
}
let bar_len = (bar_area_width as f64 * (*pct / 100.0)) as usize;
let bar = "â–ˆ".repeat(bar_len.min(bar_area_width));
let color = COLOR_MODEL_COLORS[i % COLOR_MODEL_COLORS.len()];
let label_str: String = if label.len() > max_label_width {
format!("{}…", &label[..max_label_width - 1])
} else {
format!("{:>width$}", label, width = max_label_width)
};
lines.push(Line::from(vec![
Span::styled(format!("{} ", label_str), Style::default().fg(Color::Reset)),
Span::styled(bar, Style::default().fg(color)),
Span::styled(format!(" {:5.1}%", pct), Style::default().fg(Color::Gray)),
]));
}
let paragraph = Paragraph::new(Text::from(lines));
frame.render_widget(paragraph, inner);
}
pub fn render_scrollable_table(
frame: &mut Frame,
area: Rect,
title: &str,
headers: &[&str],
rows: &[Vec<String>],
scroll_offset: u16,
is_focused: bool,
) {
let border_color = if is_focused { COLOR_ACCENT } else { COLOR_CARD_BORDER };
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color));
let inner = block.inner(area);
frame.render_widget(block, area);
render_scrollable_table_with_state(frame, inner, headers, rows, scroll_offset);
}
const COLOR_INPUT: Color = Color::Cyan;
const COLOR_OUTPUT: Color = Color::Green;
const COLOR_CACHE: Color = Color::Yellow;
pub fn render_model_stacked_bars(
frame: &mut Frame,
area: Rect,
title: &str,
model_tokens: &HashMap<String, u64>,
model_detail: &HashMap<String, ModelTokenDetail>,
scroll_offset: u16,
is_focused: bool,
) {
let t = i18n::t();
let border_color = if is_focused { COLOR_ACCENT } else { COLOR_CARD_BORDER };
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color));
let inner = block.inner(area);
frame.render_widget(block, area);
if model_tokens.is_empty() {
frame.render_widget(Paragraph::new(t.msg_no_data).centered().fg(Color::Gray), inner);
return;
}
let mut items: Vec<(String, u64)> = model_tokens
.iter()
.map(|(k, v)| (k.clone(), *v))
.collect();
items.sort_by(|a, b| b.1.cmp(&a.1));
let total: u64 = items.iter().map(|(_, v)| *v).sum();
let max_total = items.first().map(|(_, v)| *v).unwrap_or(1);
let max_label_width = items.iter().map(|(l, _)| l.len()).max().unwrap_or(10).min(16);
let bar_area_width = inner
.width
.saturating_sub(max_label_width as u16 + 28)
.max(8) as usize;
let avail_data_lines = (inner.height as usize).saturating_sub(3);
let start = scroll_offset as usize;
let mut lines: Vec<Line> = Vec::new();
lines.push(Line::from(vec![
Span::styled(
format!("{:>width$} ", "", width = max_label_width),
Style::default(),
),
Span::styled(t.legend_input, Style::default().fg(COLOR_INPUT)),
Span::styled(t.legend_output, Style::default().fg(COLOR_OUTPUT)),
Span::styled(t.legend_cache, Style::default().fg(COLOR_CACHE)),
]));
for (_i, (label, value)) in items.iter().enumerate().skip(start) {
if lines.len() > avail_data_lines {
break;
}
let detail = model_detail.get(label);
let input = detail.map(|d| d.input_tokens).unwrap_or(0);
let output = detail.map(|d| d.output_tokens).unwrap_or(0);
let cache = detail
.map(|d| d.cache_read_input_tokens + d.cache_creation_input_tokens)
.unwrap_or(0);
let bar_len = if max_total > 0 {
((*value as f64 / max_total as f64) * bar_area_width as f64) as usize
} else {
0
};
let bar_len = bar_len.min(bar_area_width);
let denom = (input + output + cache).max(1);
let input_w = ((input as f64 / denom as f64) * bar_len as f64) as usize;
let output_w = ((output as f64 / denom as f64) * bar_len as f64) as usize;
let cache_w = bar_len.saturating_sub(input_w + output_w);
let mut bar_spans: Vec<Span> = Vec::new();
if input_w > 0 {
bar_spans.push(Span::styled("â–ˆ".repeat(input_w), Style::default().fg(COLOR_INPUT)));
}
if output_w > 0 {
bar_spans.push(Span::styled("â–ˆ".repeat(output_w), Style::default().fg(COLOR_OUTPUT)));
}
if cache_w > 0 {
bar_spans.push(Span::styled("â–ˆ".repeat(cache_w), Style::default().fg(COLOR_CACHE)));
}
let pad = bar_area_width.saturating_sub(
bar_spans.iter().map(|s| s.content.chars().count()).sum::<usize>(),
);
if pad > 0 {
bar_spans.push(Span::raw(" ".repeat(pad)));
}
let label_str: String = if label.len() > max_label_width {
format!("{}…", &label[..max_label_width - 1])
} else {
format!("{:>width$}", label, width = max_label_width)
};
let cache_rate = detail.map(|d| d.cache_hit_rate()).unwrap_or(0.0);
let pct = if total > 0 {
format!("{:5.1}%", (*value as f64 / total as f64) * 100.0)
} else {
" 0.0%".to_string()
};
let mut spans = vec![
Span::styled(format!("{} ", label_str), Style::default().fg(Color::Reset)),
];
spans.extend(bar_spans);
spans.push(Span::styled(
format!(" {:>6} {}", format_tokens(*value), pct),
Style::default().fg(Color::Gray),
));
spans.push(Span::styled(
format!(" ⊚{:4.1}%", cache_rate * 100.0),
Style::default()
.fg(if cache_rate > 0.5 { Color::Green } else if cache_rate > 0.2 { Color::Yellow } else { Color::Gray }),
));
lines.push(Line::from(spans));
}
let remaining = items.len().saturating_sub(start + avail_data_lines);
if remaining > 0 {
let more_count = items.len() - (start + avail_data_lines);
lines.push(Line::from(Span::styled(
format!(" {}", t.more_models_fmt.replace("{}", &more_count.to_string())),
Style::default().fg(Color::Gray),
)));
}
let paragraph = Paragraph::new(Text::from(lines));
frame.render_widget(paragraph, inner);
}
fn heat_style(level: u8) -> Style {
match level {
0 => Style::default().bg(Color::Reset),
1 => Style::default().bg(Color::DarkGray),
2 => Style::default().fg(Color::Black).bg(Color::Green),
3 => Style::default().fg(Color::Black).bg(Color::LightGreen),
_ => Style::default().fg(Color::Black).bg(Color::Yellow).bold(),
}
}
fn heat_level(value: u64, max: u64) -> u8 {
if value == 0 { return 0; }
if max == 0 { return 4; }
let pct = value as f64 / max as f64;
if pct <= 0.25 { 1 } else if pct <= 0.50 { 2 } else if pct <= 0.75 { 3 } else { 4 }
}
const DAY_HEADERS: &[&str] = &["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
const MONTH_NAMES: &[&str] = &["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
fn push_cell(spans: &mut Vec<Span>, cell_w: u16, level: u8) {
spans.push(Span::styled(
" ".repeat(cell_w as usize),
heat_style(level),
));
}
fn push_blank(spans: &mut Vec<Span>, cell_w: u16) {
spans.push(Span::styled(
" ".repeat(cell_w as usize),
Style::default(),
));
}
pub fn render_monthly_heatmap(
frame: &mut Frame,
area: Rect,
title: &str,
data: &[(String, u64)],
year: i32,
month: u32,
) {
let t = i18n::t();
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(COLOR_CARD_BORDER));
let inner = block.inner(area);
frame.render_widget(block, area);
if data.is_empty() {
frame.render_widget(Paragraph::new(t.msg_no_data).centered().fg(Color::Gray), inner);
return;
}
let mut day_values: HashMap<u32, u64> = HashMap::new();
let mut max_val: u64 = 0;
for (date_str, val) in data {
if let Ok(d) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
if d.year() == year && d.month() == month {
day_values.insert(d.day(), *val);
max_val = max_val.max(*val);
}
}
}
if day_values.is_empty() {
frame.render_widget(Paragraph::new(t.msg_no_data).centered().fg(Color::Gray), inner);
return;
}
let first = chrono::NaiveDate::from_ymd_opt(year, month, 1).unwrap();
let start_dow = first.weekday().num_days_from_monday();
let days_in_month = if month == 12 {
chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap().pred_opt().unwrap().day()
} else {
chrono::NaiveDate::from_ymd_opt(year, month + 1, 1).unwrap().pred_opt().unwrap().day()
};
let total_cols = 7u16;
let gap = 1u16;
let left_pad = 0u16;
let usable_w = inner.width.saturating_sub(left_pad + (total_cols - 1) * gap);
let cell_w = (usable_w / total_cols).max(2);
let avail_rows = inner.height.saturating_sub(3) as usize;
let total_rows = ((start_dow + days_in_month + 6) / 7) as usize;
let max_rows = avail_rows.min(total_rows);
let gap_str = " ".repeat(gap as usize);
let gap_span = Span::raw(gap_str.as_str());
let mut header_spans: Vec<Span> = vec![Span::raw(" ".repeat(left_pad as usize))];
for (i, h) in DAY_HEADERS.iter().enumerate() {
if i > 0 { header_spans.push(gap_span.clone()); }
header_spans.push(Span::styled(
format!("{:^width$}", h, width = cell_w as usize),
Style::default().fg(Color::Gray),
));
}
let mut lines: Vec<Line> = vec![Line::from(header_spans)];
for week in 0..max_rows {
let mut spans = vec![Span::raw(" ".repeat(left_pad as usize))];
for col in 0..7u16 {
if col > 0 { spans.push(gap_span.clone()); }
let dow = col as u32;
if week == 0 && dow < start_dow {
push_blank(&mut spans, cell_w);
} else {
let day_num = (week as u32) * 7 + dow - start_dow + 1;
if day_num > days_in_month {
push_blank(&mut spans, cell_w);
} else {
let val = day_values.get(&day_num).copied().unwrap_or(0);
let level = heat_level(val, max_val);
push_cell(&mut spans, cell_w, level);
}
}
}
lines.push(Line::from(spans));
}
lines.push(Line::from(legend_spans()));
frame.render_widget(Paragraph::new(Text::from(lines)), inner);
}
pub fn render_yearly_heatmap(
frame: &mut Frame,
area: Rect,
title: &str,
data: &[(String, u64)],
year: i32,
) {
let t = i18n::t();
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(COLOR_CARD_BORDER));
let inner = block.inner(area);
frame.render_widget(block, area);
if data.is_empty() {
frame.render_widget(Paragraph::new(t.msg_no_data).centered().fg(Color::Gray), inner);
return;
}
let mut grid: HashMap<(u32, u32), u64> = HashMap::new();
let mut max_val: u64 = 0;
let mut min_week: u32 = 53; let mut max_week: u32 = 1;
for (date_str, val) in data {
if let Ok(d) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
if d.year() == year {
let w = d.iso_week().week();
let dow = d.weekday().num_days_from_monday();
grid.insert((w, dow), *val);
max_val = max_val.max(*val);
min_week = min_week.min(w);
max_week = max_week.max(w);
}
}
}
if grid.is_empty() {
frame.render_widget(Paragraph::new(t.msg_no_data).centered().fg(Color::Gray), inner);
return;
}
let cell_w = 2u16;
let gap = 1u16;
let label_w = 3u16;
let usable_w = inner.width.saturating_sub(label_w);
let avail_cols = (usable_w as usize) / (cell_w as usize + gap as usize);
let total_weeks = (max_week - min_week + 1) as usize;
let step = if total_weeks > avail_cols { (total_weeks + avail_cols - 1) / avail_cols } else { 1 };
let avail_height = inner.height.saturating_sub(3) as usize;
let gap_str = " ".repeat(gap as usize);
let gap_span = Span::raw(gap_str.as_str());
let mut lines: Vec<Line> = Vec::new();
let mut m_spans = vec![Span::raw(" ".repeat(label_w as usize))];
let mut last_m: u32 = 0;
for w in (min_week..=max_week).step_by(step) {
if w > min_week { m_spans.push(gap_span.clone()); }
if let Some(d) = chrono::NaiveDate::from_isoywd_opt(year, w, chrono::Weekday::Mon) {
let m = d.month();
if m != last_m {
m_spans.push(Span::styled(
format!("{:^width$}", MONTH_NAMES[(m - 1) as usize], width = cell_w as usize),
Style::default().fg(Color::Gray),
));
last_m = m;
} else {
m_spans.push(Span::raw(" ".repeat(cell_w as usize)));
}
} else {
m_spans.push(Span::raw(" ".repeat(cell_w as usize)));
}
}
lines.push(Line::from(m_spans));
for dow in 0..7u32 {
let mut spans = vec![Span::styled(
format!("{:<width$}", DAY_HEADERS[dow as usize], width = label_w as usize),
Style::default().fg(Color::Gray),
)];
for w in (min_week..=max_week).step_by(step) {
if w > min_week { spans.push(gap_span.clone()); }
let val = grid.get(&(w, dow)).copied().unwrap_or(0);
let level = heat_level(val, max_val);
push_cell(&mut spans, cell_w, level);
}
if (dow as usize) < avail_height {
lines.push(Line::from(spans));
}
}
lines.push(Line::from(legend_spans()));
frame.render_widget(Paragraph::new(Text::from(lines)), inner);
}
fn legend_spans() -> Vec<Span<'static>> {
let t = i18n::t();
vec![
Span::styled(format!("{} ", t.heatmap_less), Style::default().fg(Color::Gray)),
Span::styled(" ", heat_style(0)),
Span::styled(" ", heat_style(1)),
Span::styled(" ", heat_style(2)),
Span::styled(" ", heat_style(3)),
Span::styled(" ", heat_style(4)),
Span::styled(format!(" {}", t.heatmap_more), Style::default().fg(Color::Gray)),
]
}
pub fn render_mcp_stats(
frame: &mut Frame,
area: Rect,
title: &str,
tool_calls: &HashMap<String, u64>,
scroll_offset: u16,
is_focused: bool,
) {
let t = i18n::t();
let border_color = if is_focused { COLOR_ACCENT } else { COLOR_CARD_BORDER };
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color));
let inner = block.inner(area);
frame.render_widget(block, area);
let mut items: Vec<(String, u64)> = tool_calls
.iter()
.filter(|(n, _)| n.starts_with("mcp__"))
.map(|(n, c)| (n.clone(), *c))
.collect();
items.sort_by(|a, b| b.1.cmp(&a.1));
if items.is_empty() {
frame.render_widget(
Paragraph::new(t.msg_no_mcp).centered().fg(Color::Gray),
inner,
);
return;
}
let shorten = |name: &str| -> String {
let body = name.strip_prefix("mcp__").unwrap_or(name);
body.replace("__", ":")
};
let max_val = items.first().map(|(_, c)| *c).unwrap_or(1);
let max_label = items.iter().map(|(n, _)| shorten(n).len()).max().unwrap_or(10).min(20);
let avail = (inner.height as usize).saturating_sub(2);
let bar_w = inner.width.saturating_sub(max_label as u16 + 14).max(5) as usize;
let start = scroll_offset as usize;
let mut lines: Vec<Line> = Vec::new();
for (i, (name, count)) in items.iter().enumerate().skip(start) {
if lines.len() >= avail { break; }
let short = shorten(name);
let label = if short.len() > max_label {
format!("{}…", &short[..max_label - 1])
} else {
format!("{:>width$}", short, width = max_label)
};
let bar_len = if max_val > 0 {
((*count as f64 / max_val as f64) * bar_w as f64) as usize
} else { 0 };
let color = COLOR_MODEL_COLORS[i % COLOR_MODEL_COLORS.len()];
lines.push(Line::from(vec![
Span::styled(format!("{} ", label), Style::default().fg(Color::Reset)),
Span::styled("â–ˆ".repeat(bar_len.min(bar_w)), Style::default().fg(color)),
Span::styled(format!(" {}", count), Style::default().fg(Color::Gray)),
]));
}
let paragraph = Paragraph::new(Text::from(lines));
frame.render_widget(paragraph, inner);
}
pub fn render_language_bars(
frame: &mut Frame,
area: Rect,
title: &str,
languages: &HashMap<String, f64>,
scroll_offset: u16,
is_focused: bool,
) {
let mut items: Vec<(String, f64)> = languages
.iter()
.map(|(k, v)| (k.clone(), *v))
.collect();
items.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
render_percentage_bars(frame, area, title, &items, scroll_offset, is_focused);
}
pub fn render_tool_usage_table(
frame: &mut Frame,
area: Rect,
title: &str,
model_detail: &HashMap<String, ModelTokenDetail>,
scroll_offset: u16,
is_focused: bool,
) {
let t = i18n::t();
let border_color = if is_focused { COLOR_ACCENT } else { COLOR_CARD_BORDER };
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color));
let inner = block.inner(area);
frame.render_widget(block, area);
let mut items: Vec<(String, u64, u64, u64)> = model_detail
.iter()
.map(|(name, d)| {
(name.clone(), d.tool_call_count, d.web_search_requests, d.web_fetch_requests)
})
.collect();
items.sort_by(|a, b| {
(b.1 + b.2 + b.3).cmp(&(a.1 + a.2 + a.3))
});
items.retain(|(_, tc, s, f)| *tc + *s + *f > 0);
if items.is_empty() {
frame.render_widget(
Paragraph::new(t.msg_no_tool_calls).centered().fg(Color::Gray),
inner,
);
return;
}
let headers = &[t.th_model, t.th_tool_calls, t.th_web_search, t.th_web_fetch, t.th_total];
let rows: Vec<Vec<String>> = items
.iter()
.map(|(name, tc, s, f)| {
vec![
name.clone(),
tc.to_string(),
s.to_string(),
f.to_string(),
(*tc + *s + *f).to_string(),
]
})
.collect();
render_scrollable_table_with_state(
frame, inner, headers, &rows, scroll_offset,
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_heat_level_zero_value() {
assert_eq!(heat_level(0, 100), 0);
assert_eq!(heat_level(0, 0), 0);
}
#[test]
fn test_heat_level_max_zero_with_nonzero_value() {
assert_eq!(heat_level(10, 0), 4);
}
#[test]
fn test_heat_level_quartiles() {
let max = 100;
assert_eq!(heat_level(1, max), 1);
assert_eq!(heat_level(25, max), 1);
assert_eq!(heat_level(26, max), 2);
assert_eq!(heat_level(50, max), 2);
assert_eq!(heat_level(51, max), 3);
assert_eq!(heat_level(75, max), 3);
assert_eq!(heat_level(76, max), 4);
assert_eq!(heat_level(100, max), 4);
}
#[test]
fn test_heat_level_full_value() {
assert_eq!(heat_level(100, 100), 4);
}
#[test]
fn test_heat_level_small_max() {
assert_eq!(heat_level(1, 4), 1); assert_eq!(heat_level(2, 4), 2); assert_eq!(heat_level(3, 4), 3); assert_eq!(heat_level(4, 4), 4); }
#[test]
fn test_heat_level_boundary_at_one() {
assert_eq!(heat_level(1, 1), 4); }
#[test]
fn test_heat_style_all_levels_different() {
let s0 = heat_style(0);
let s1 = heat_style(1);
let s2 = heat_style(2);
let s3 = heat_style(3);
let s4 = heat_style(4);
assert_ne!(format!("{:?}", s0), format!("{:?}", s1));
assert_ne!(format!("{:?}", s1), format!("{:?}", s2));
assert_ne!(format!("{:?}", s2), format!("{:?}", s3));
assert_ne!(format!("{:?}", s3), format!("{:?}", s4));
}
#[test]
fn test_legend_spans_has_7_elements() {
let _ = crate::i18n::init();
let spans = legend_spans();
assert_eq!(spans.len(), 7);
}
#[test]
fn test_push_cell_adds_span() {
let mut spans = Vec::new();
push_cell(&mut spans, 3, 2);
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].content.as_ref(), " ");
}
#[test]
fn test_push_blank_adds_span() {
let mut spans = Vec::new();
push_blank(&mut spans, 4);
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].content.as_ref(), " ");
}
}
fn render_scrollable_table_with_state(
frame: &mut Frame,
inner: Rect,
headers: &[&str],
rows: &[Vec<String>],
scroll_offset: u16,
) {
let t = i18n::t();
if rows.is_empty() {
frame.render_widget(
Paragraph::new(t.msg_no_data).centered().fg(Color::Gray),
inner,
);
return;
}
let header_cells: Vec<Cell> = headers
.iter()
.map(|h| Cell::from(Span::styled(*h, Style::default().fg(COLOR_HEADER).bold())))
.collect();
let header_row = Row::new(header_cells).style(Style::default().bg(Color::Reset));
let visible_count = (inner.height as usize).saturating_sub(2);
let start = scroll_offset as usize;
let data_rows: Vec<Row> = rows
.iter()
.skip(start)
.take(visible_count)
.enumerate()
.map(|(i, row)| {
let cells: Vec<Cell> = row
.iter()
.map(|c| Cell::from(Span::styled(c.to_string(), Style::default().fg(Color::Reset))))
.collect();
let style = if i % 2 == 0 {
Style::default()
} else {
Style::default().bg(COLOR_TABLE_ALT)
};
Row::new(cells).style(style)
})
.collect();
let constraints: Vec<Constraint> = headers
.iter()
.enumerate()
.map(|(i, h)| {
if i == 1 && headers.len() > 2 {
Constraint::Length(h.len() as u16 + 4)
} else {
Constraint::Ratio(1, headers.len() as u32)
}
})
.collect();
let table = Table::new(data_rows, constraints)
.header(header_row)
.row_highlight_style(Style::default().bg(Color::DarkGray));
frame.render_widget(table, inner);
}