use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style, Stylize};
use ratatui::text::{Line as TLine, Span};
use ratatui::widgets::{
Cell, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, Table,
Widget,
};
use yaml_rust2::Yaml;
use super::{clip, dur, hms, i64_of, kind, pairs, service, stamp};
use crate::term;
fn paint(w: usize, h: usize, f: impl FnOnce(Rect, &mut Buffer)) -> Vec<String> {
let area = Rect::new(0, 0, w as u16, h as u16);
let mut buf = Buffer::empty(area);
f(area, &mut buf);
flatten(&buf, area)
}
fn flatten(buf: &Buffer, area: Rect) -> Vec<String> {
let mut out = Vec::with_capacity(area.height as usize);
for y in area.top()..area.bottom() {
let mut line = String::new();
let mut open: Option<(Color, Modifier)> = None;
for x in area.left()..area.right() {
let Some(c) = buf.cell((x, y)) else { continue };
let key = (c.fg, c.modifier);
if open != Some(key) {
line.push_str(term::RESET);
line.push_str(&sgr(c.fg, c.modifier));
open = Some(key);
}
line.push_str(c.symbol());
}
line.push_str(term::RESET);
out.push(line);
}
out
}
fn sgr(fg: Color, m: Modifier) -> String {
let mut s = String::new();
if m.contains(Modifier::BOLD) {
s.push_str(term::BOLD);
}
if m.contains(Modifier::DIM) {
s.push_str(term::DIM);
}
if m.contains(Modifier::REVERSED) {
s.push_str(term::REV);
}
s.push_str(match fg {
Color::Red => term::RED,
Color::Green => term::GREEN,
Color::Yellow => term::YELLOW,
Color::Blue => term::BLUE,
Color::Magenta => term::MAGENTA,
Color::Cyan => term::CYAN,
_ => "",
});
s
}
pub fn sev(n: i64) -> Style {
match n {
17.. => Style::default().fg(Color::Red),
13..=16 => Style::default().fg(Color::Yellow),
9..=12 => Style::default().fg(Color::Green),
_ => Style::default().add_modifier(Modifier::DIM),
}
}
pub fn row(w: usize, spans: Vec<Span<'_>>) -> String {
row_styled(w, Style::default(), spans)
}
pub fn row_styled(w: usize, base: Style, spans: Vec<Span<'_>>) -> String {
paint(w, 1, |area, buf| {
Paragraph::new(TLine::from(spans))
.style(base)
.render(area, buf);
})
.pop()
.unwrap_or_default()
}
pub fn hl(on: bool) -> Style {
match on {
true => Style::default().add_modifier(Modifier::REVERSED),
false => Style::default(),
}
}
pub fn columns(w: usize, base: Style, cells: Vec<(u16, Vec<Span<'_>>)>) -> String {
paint(w, 1, |area, buf| {
buf.set_style(area, base);
let widths: Vec<Constraint> = cells.iter().map(|(n, _)| Constraint::Length(*n)).collect();
let areas = Layout::horizontal(widths).split(area);
for (a, (_, spans)) in areas.iter().zip(cells) {
Paragraph::new(TLine::from(spans)).render(*a, buf);
}
})
.pop()
.unwrap_or_default()
}
pub fn bar(w: usize, left: Vec<Span<'_>>, right: Vec<Span<'_>>) -> String {
let rw = right.iter().map(Span::width).sum::<usize>() as u16;
paint(w, 1, |area, buf| {
let [l, r] = Layout::horizontal([Constraint::Min(0), Constraint::Length(rw)]).areas(area);
Paragraph::new(TLine::from(left)).render(l, buf);
Paragraph::new(TLine::from(right)).render(r, buf);
})
.pop()
.unwrap_or_default()
}
pub fn rule(w: usize, title: &str) -> String {
let used = 2 + title.chars().count();
row(
w,
vec![
"──".dim(),
title.to_string().dim(),
"─".repeat(w.saturating_sub(used)).dim(),
],
)
}
pub fn log_list(rows: &[Yaml], w: usize, h: usize, sel: usize, start: usize) -> Vec<String> {
let visible: Vec<Row> = rows
.iter()
.skip(start)
.take(h)
.enumerate()
.map(|(i, row)| {
let picked = start + i == sel;
let dim = match picked {
true => Style::default(),
false => Style::default().add_modifier(Modifier::DIM),
};
let n = row["severity_number"].as_i64().unwrap_or(0);
Row::new(vec![
Cell::from(format!(" {}", hms(i64_of(&row["time_unix_nano"])))),
Cell::from(clip(row["severity_text"].as_str().unwrap_or("-"), 6))
.style(if picked { Style::default() } else { sev(n) }),
Cell::from(clip(service(row), 16)).style(dim),
Cell::from(row["body"].as_str().unwrap_or("").to_owned()),
])
.style(hl(picked))
})
.collect();
paint(w, h, |area, buf| {
let body = Rect {
width: area.width.saturating_sub(1),
..area
};
let table = Table::new(
visible,
[
Constraint::Length(13),
Constraint::Length(6),
Constraint::Length(16),
Constraint::Min(0),
],
)
.column_spacing(1);
Widget::render(table, body, buf);
let mut state = ScrollbarState::new(rows.len()).position(sel);
Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None)
.render(area, buf, &mut state);
})
}
pub fn detail(row: &Yaml, w: usize) -> Vec<String> {
let mut fields: Vec<(String, String, Color)> = Vec::new();
for (k, v) in pairs(row) {
if matches!(k.as_str(), "attributes" | "events" | "links" | "points") {
continue;
}
let pretty = match k.as_str() {
"time_unix_nano" | "observed_time_unix_nano" | "start_time_unix_nano" => {
v.parse::<i64>().map(stamp).unwrap_or_else(|_| v.clone())
}
"duration_nano" => v.parse::<i64>().map(dur).unwrap_or_else(|_| v.clone()),
"kind" => v
.parse::<i64>()
.map(|k| kind(k).to_owned())
.unwrap_or_else(|_| v.clone()),
_ => v.clone(),
};
fields.push((k, pretty, Color::Reset));
}
let mut groups = vec![("", fields)];
let attrs = pairs(&row["attributes"]);
if !attrs.is_empty() {
groups.push((
" attributes ",
attrs
.into_iter()
.map(|(k, v)| (k, v, Color::Cyan))
.collect(),
));
}
for (label, key) in [(" events ", "events"), (" links ", "links")] {
let items = row[key].as_vec().map_or(&[][..], |v| v.as_slice());
if items.is_empty() {
continue;
}
let mut rows = Vec::new();
for it in items {
for (k, v) in pairs(it) {
if k == "attributes" {
continue;
}
rows.push((k, v, Color::Reset));
}
for (k, v) in pairs(&it["attributes"]) {
rows.push((format!(" {k}"), v, Color::Cyan));
}
}
groups.push((label, rows));
}
let keyw = groups
.iter()
.flat_map(|(_, rows)| rows.iter())
.map(|(k, _, _)| k.chars().count())
.max()
.unwrap_or(0)
.min(w / 3) as u16;
let mut out = Vec::new();
for (label, rows) in groups {
if rows.is_empty() {
continue;
}
if !label.is_empty() {
out.push(rule(w, label));
}
let table: Vec<Row> = rows
.iter()
.map(|(k, v, c)| {
Row::new(vec![
Cell::from(k.clone()).style(Style::default().add_modifier(Modifier::DIM)),
Cell::from(v.clone()).style(Style::default().fg(*c)),
])
})
.collect();
out.extend(paint(w, rows.len(), |area, buf| {
let body = Rect {
x: 2,
width: area.width.saturating_sub(2),
..area
};
let t =
Table::new(table, [Constraint::Length(keyw), Constraint::Min(0)]).column_spacing(2);
Widget::render(t, body, buf);
}));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::tests::strip;
#[test]
fn a_long_attribute_key_does_not_abut_its_value() {
let y = yaml_rust2::YamlLoader::load_from_str(
"body: hi\nattributes:\n deployment.environment.name: prod\n",
)
.unwrap()
.remove(0);
let out = detail(&y, 100);
let line = out
.iter()
.map(|l| strip(l))
.find(|l| l.contains("prod"))
.expect("the attribute is rendered");
assert!(
line.contains("name prod") || line.contains("name prod"),
"key and value must not run together: {line:?}"
);
}
#[test]
fn an_empty_section_is_not_a_heading_over_nothing() {
let y = yaml_rust2::YamlLoader::load_from_str(
"attributes:\n service.name: api\npoints:\n - 1\nevents:\n - attributes: {}\n",
)
.unwrap()
.remove(0);
let out: Vec<String> = detail(&y, 80).iter().map(|l| strip(l)).collect();
assert!(out[0].contains("attributes"), "{out:?}");
assert!(out.iter().any(|l| l.contains("api")), "{out:?}");
assert!(!out.iter().any(|l| l.contains("events")), "{out:?}");
}
}