use ratatui::{
Frame,
layout::Rect,
style::Style,
text::{Line, Span},
widgets::Paragraph,
};
use crate::options::OptionSchema;
use crate::payload::{BarsData, Body};
use crate::theme::{self, ColorKey, Theme};
use super::{Registry, RenderOptions, Renderer, Shape};
const COLOR_KEYS: &[ColorKey] = &[theme::TEXT, theme::TEXT_DIM];
const OPTION_SCHEMAS: &[OptionSchema] = &[
OptionSchema {
name: "max_items",
type_hint: "positive integer",
required: false,
default: Some("all bars"),
description: "Cap on rendered ranks. Keeps the top N by value when the input has more bars than the cap.",
},
OptionSchema {
name: "style",
type_hint: "\"number\" | \"medal\" | \"none\"",
required: false,
default: Some("\"number\""),
description: "Rank-prefix glyph. `number` (default) emits `1.` / `2.` / `3.`. `medal` emits π₯ / π₯ / π₯ for the top three then numbers for the rest. `none` omits the prefix.",
},
OptionSchema {
name: "align",
type_hint: "\"left\" | \"center\" | \"right\"",
required: false,
default: Some("\"left\""),
description: "Horizontal alignment of the whole ranking within its cell.",
},
];
const MEDALS: [&str; 3] = ["π₯", "π₯", "π₯"];
const COLUMN_GAP: &str = " ";
pub struct ListRankingRenderer;
impl Renderer for ListRankingRenderer {
fn name(&self) -> &str {
"list_ranking"
}
fn description(&self) -> &'static str {
"Rank-prefixed table preserving the fetcher's bar order: rank prefix (`1.` / `2.` numbers, π₯/π₯/π₯ medals, or none), label, and right-aligned value column. The text-first sibling of `chart_bar` β pick this when the values matter more than their relative bar lengths. Sort is the fetcher's responsibility; `max_items` truncates from the head."
}
fn accepts(&self) -> &[Shape] {
&[Shape::Bars]
}
fn color_keys(&self) -> &[ColorKey] {
COLOR_KEYS
}
fn option_schemas(&self) -> &[OptionSchema] {
OPTION_SCHEMAS
}
fn render(
&self,
frame: &mut Frame,
area: Rect,
body: &Body,
opts: &RenderOptions,
theme: &Theme,
_registry: &Registry,
) {
if let Body::Bars(d) = body {
render_ranking(frame, area, d, opts, theme);
}
}
}
fn render_ranking(
frame: &mut Frame,
area: Rect,
data: &BarsData,
opts: &RenderOptions,
theme: &Theme,
) {
if area.width == 0 || area.height == 0 {
return;
}
let rows = capped_rows(data, opts.max_items);
let style = opts.style.as_deref().unwrap_or("number");
let prefixes: Vec<String> = (0..rows.len()).map(|i| rank_prefix(i, style)).collect();
let widths = column_widths(&prefixes, &rows);
let target = align_rect(area, widths.total() as u16, opts.align.as_deref());
let prefix_part = if widths.prefix > 0 {
widths.prefix + 1
} else {
0
};
let fixed = prefix_part + COLUMN_GAP.len() + widths.value;
let label_width = (target.width as usize).saturating_sub(fixed);
let effective = Widths {
prefix: widths.prefix,
label: label_width,
value: widths.value,
};
let lines: Vec<Line> = rows
.iter()
.zip(prefixes.iter())
.map(|(row, prefix)| compose_line(prefix, &row.label, &row.display, effective, theme))
.collect();
frame.render_widget(
Paragraph::new(lines).style(Style::default().fg(theme.text)),
target,
);
}
struct Row {
label: String,
display: String,
}
fn capped_rows(data: &BarsData, cap: Option<usize>) -> Vec<Row> {
let mut rows: Vec<Row> = data
.bars
.iter()
.map(|b| Row {
label: b.label.clone(),
display: b.value_label.clone().unwrap_or_else(|| b.value.to_string()),
})
.collect();
if let Some(n) = cap {
rows.truncate(n);
}
rows
}
fn rank_prefix(index: usize, style: &str) -> String {
match style {
"none" => String::new(),
"medal" => MEDALS
.get(index)
.map(|m| (*m).to_string())
.unwrap_or_else(|| format!("{}.", index + 1)),
_ => format!("{}.", index + 1),
}
}
#[derive(Debug, Clone, Copy)]
struct Widths {
prefix: usize,
label: usize,
value: usize,
}
impl Widths {
fn total(self) -> usize {
let prefix_part = if self.prefix > 0 { self.prefix + 1 } else { 0 };
prefix_part + self.label + COLUMN_GAP.len() + self.value
}
}
fn column_widths(prefixes: &[String], rows: &[Row]) -> Widths {
Widths {
prefix: prefixes.iter().map(|p| display_width(p)).max().unwrap_or(0),
label: rows
.iter()
.map(|r| r.label.chars().count())
.max()
.unwrap_or(0),
value: rows
.iter()
.map(|r| r.display.chars().count())
.max()
.unwrap_or(0),
}
}
fn compose_line<'a>(
prefix: &str,
label: &str,
value: &str,
widths: Widths,
theme: &Theme,
) -> Line<'a> {
let mut spans: Vec<Span<'a>> = Vec::with_capacity(4);
if widths.prefix > 0 {
let pad = widths.prefix.saturating_sub(display_width(prefix));
spans.push(Span::styled(
format!("{}{prefix} ", " ".repeat(pad)),
Style::default().fg(theme.text_dim),
));
}
spans.push(Span::raw(fit_label(label, widths.label)));
spans.push(Span::raw(COLUMN_GAP));
spans.push(Span::raw(pad_left(value, widths.value)));
Line::from(spans)
}
fn fit_label(label: &str, width: usize) -> String {
if width == 0 {
return String::new();
}
let n = label.chars().count();
if n <= width {
let mut out = String::with_capacity(width);
out.push_str(label);
out.extend(std::iter::repeat_n(' ', width - n));
return out;
}
if width == 1 {
return "β¦".to_string();
}
let take = width - 1;
let mut out: String = label.chars().take(take).collect();
out.push('β¦');
out
}
fn align_rect(area: Rect, content_width: u16, align: Option<&str>) -> Rect {
if content_width == 0 || content_width >= area.width {
return area;
}
let offset = match align {
Some("center") => (area.width - content_width) / 2,
Some("right") => area.width - content_width,
_ => return area,
};
Rect {
x: area.x + offset,
y: area.y,
width: content_width,
height: area.height,
}
}
fn pad_left(s: &str, width: usize) -> String {
let n = s.chars().count();
if n >= width {
return s.to_string();
}
let mut out = String::with_capacity(width);
out.extend(std::iter::repeat_n(' ', width - n));
out.push_str(s);
out
}
fn display_width(s: &str) -> usize {
s.chars()
.map(|c| {
if matches!(c, 'π₯' | 'π₯' | 'π₯') {
2
} else {
1
}
})
.sum()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::payload::{Bar, BarsData, Body, Payload};
use crate::render::test_utils::{line_text, render_to_buffer_with_spec};
use crate::render::{Registry, RenderSpec};
fn payload(bars: &[(&str, u64)]) -> Payload {
Payload {
icon: None,
status: None,
format: None,
body: Body::Bars(BarsData {
bars: bars
.iter()
.map(|(l, v)| Bar {
label: (*l).into(),
value: *v,
value_label: None,
})
.collect(),
}),
}
}
fn render_with(spec: &RenderSpec, p: &Payload, w: u16, h: u16) -> ratatui::buffer::Buffer {
let registry = Registry::with_builtins();
render_to_buffer_with_spec(p, Some(spec), ®istry, w, h)
}
#[test]
fn preserves_fetcher_bar_order_with_numeric_prefix() {
let p = payload(&[("alice", 3), ("bob", 7), ("carol", 5)]);
let spec = RenderSpec::Short("list_ranking".into());
let buf = render_with(&spec, &p, 30, 3);
let row0 = line_text(&buf, 0);
let row1 = line_text(&buf, 1);
let row2 = line_text(&buf, 2);
assert!(
row0.contains("1.") && row0.contains("alice"),
"row0: {row0:?}"
);
assert!(
row1.contains("2.") && row1.contains("bob"),
"row1: {row1:?}"
);
assert!(
row2.contains("3.") && row2.contains("carol"),
"row2: {row2:?}"
);
}
#[test]
fn max_items_truncates_from_the_head_preserving_order() {
#[derive(serde::Deserialize)]
struct W {
render: RenderSpec,
}
let w: W = toml::from_str(r#"render = { type = "list_ranking", max_items = 2 }"#).unwrap();
let p = payload(&[("a", 1), ("b", 9), ("c", 5), ("d", 7)]);
let buf = render_with(&w.render, &p, 30, 4);
let joined: String = (0..4).map(|y| line_text(&buf, y)).collect();
assert!(joined.contains("a"), "missing first: {joined:?}");
assert!(joined.contains("b"), "missing second: {joined:?}");
assert!(!joined.contains("c"), "third should be capped: {joined:?}");
assert!(!joined.contains("d"), "fourth should be capped: {joined:?}");
}
#[test]
fn medal_style_decorates_top_three() {
#[derive(serde::Deserialize)]
struct W {
render: RenderSpec,
}
let w: W =
toml::from_str(r#"render = { type = "list_ranking", style = "medal" }"#).unwrap();
let p = payload(&[("a", 9), ("b", 7), ("c", 5), ("d", 3)]);
let buf = render_with(&w.render, &p, 30, 4);
let r0 = line_text(&buf, 0);
let r3 = line_text(&buf, 3);
assert!(r0.contains("π₯"), "row0: {r0:?}");
assert!(line_text(&buf, 1).contains("π₯"));
assert!(line_text(&buf, 2).contains("π₯"));
assert!(r3.contains("4."), "row3: {r3:?}");
}
#[test]
fn value_label_overrides_numeric_value_when_present() {
let payload = Payload {
icon: None,
status: None,
format: None,
body: Body::Bars(BarsData {
bars: vec![
Bar {
label: "Game A".into(),
value: 4830,
value_label: Some("80h".into()),
},
Bar {
label: "Game B".into(),
value: 12_000,
value_label: Some("200h".into()),
},
],
}),
};
let spec = RenderSpec::Short("list_ranking".into());
let buf = render_with(&spec, &payload, 30, 2);
let row0 = line_text(&buf, 0);
let row1 = line_text(&buf, 1);
assert!(row0.contains("Game A") && row0.contains("80h"), "{row0:?}");
assert!(row1.contains("Game B") && row1.contains("200h"), "{row1:?}");
assert!(!row0.contains("4830"), "raw value leaked: {row0:?}");
assert!(!row1.contains("12000"), "raw value leaked: {row1:?}");
}
#[test]
fn missing_value_label_falls_back_to_numeric_value() {
let payload = Payload {
icon: None,
status: None,
format: None,
body: Body::Bars(BarsData {
bars: vec![Bar {
label: "alpha".into(),
value: 42,
value_label: None,
}],
}),
};
let spec = RenderSpec::Short("list_ranking".into());
let buf = render_with(&spec, &payload, 30, 1);
let row0 = line_text(&buf, 0);
assert!(row0.contains("42"), "{row0:?}");
}
#[test]
fn style_none_omits_prefix() {
#[derive(serde::Deserialize)]
struct W {
render: RenderSpec,
}
let w: W = toml::from_str(r#"render = { type = "list_ranking", style = "none" }"#).unwrap();
let p = payload(&[("alpha", 3), ("beta", 9)]);
let buf = render_with(&w.render, &p, 30, 2);
let r0 = line_text(&buf, 0);
assert!(!r0.contains("1."), "row0: {r0:?}");
assert!(r0.trim_start().starts_with("alpha"), "row0: {r0:?}");
}
#[test]
fn ties_preserve_input_order() {
let p = payload(&[("alpha", 5), ("beta", 5), ("gamma", 5)]);
let spec = RenderSpec::Short("list_ranking".into());
let buf = render_with(&spec, &p, 30, 3);
assert!(line_text(&buf, 0).contains("alpha"));
assert!(line_text(&buf, 1).contains("beta"));
assert!(line_text(&buf, 2).contains("gamma"));
}
#[test]
fn align_center_offsets_block() {
#[derive(serde::Deserialize)]
struct W {
render: RenderSpec,
}
let w: W =
toml::from_str(r#"render = { type = "list_ranking", align = "center" }"#).unwrap();
let p = payload(&[("a", 1)]);
let buf = render_with(&w.render, &p, 20, 1);
let row = line_text(&buf, 0);
assert!(row.starts_with(' '), "expected leading spaces: {row:?}");
}
#[test]
fn value_column_right_aligns_across_rows() {
let p = payload(&[("a", 142), ("b", 9)]);
let spec = RenderSpec::Short("list_ranking".into());
let buf = render_with(&spec, &p, 30, 2);
let r0 = line_text(&buf, 0).trim_end().to_string();
let r1 = line_text(&buf, 1).trim_end().to_string();
assert!(r0.ends_with("142"), "row0 should end with 142: {r0:?}");
assert!(r1.ends_with('9'), "row1 should end with 9: {r1:?}");
assert_eq!(
r0.len(),
r1.len(),
"trailing column should align: {r0:?} vs {r1:?}"
);
}
#[test]
fn empty_area_does_not_panic() {
let p = payload(&[("a", 1)]);
let spec = RenderSpec::Short("list_ranking".into());
let _ = render_with(&spec, &p, 0, 0);
}
#[test]
fn rank_prefix_falls_back_to_number_past_third_medal() {
assert_eq!(rank_prefix(0, "medal"), "π₯");
assert_eq!(rank_prefix(2, "medal"), "π₯");
assert_eq!(rank_prefix(3, "medal"), "4.");
assert_eq!(rank_prefix(0, "number"), "1.");
assert_eq!(rank_prefix(0, "none"), "");
}
#[test]
fn display_width_counts_medals_as_two_cells() {
assert_eq!(display_width("π₯"), 2);
assert_eq!(display_width("10."), 3);
}
#[test]
fn fit_label_covers_pad_truncate_and_zero_width_branches() {
assert_eq!(fit_label("anything", 0), "");
assert_eq!(fit_label("ab", 5), "ab ");
assert_eq!(fit_label("hello", 1), "β¦");
assert_eq!(fit_label("hello", 3), "heβ¦");
}
#[test]
fn pad_left_prepends_spaces_only_when_shorter_than_width() {
assert_eq!(pad_left("9", 4), " 9");
assert_eq!(pad_left("142", 3), "142");
assert_eq!(pad_left("longer", 2), "longer");
}
#[test]
fn align_rect_offsets_for_center_and_right_and_passes_through_otherwise() {
let area = Rect::new(2, 1, 20, 3);
assert_eq!(align_rect(area, 10, Some("center")).x, 2 + 5);
let right = align_rect(area, 10, Some("right"));
assert_eq!(right.x, 2 + 10);
assert_eq!(right.width, 10);
assert_eq!(align_rect(area, 10, None), area);
assert_eq!(align_rect(area, 0, Some("center")), area);
assert_eq!(align_rect(area, 99, Some("right")), area);
}
}