use plurimus::core::ratatui_core::buffer::Buffer;
use plurimus::core::ratatui_core::layout::Rect;
use plurimus::core::ratatui_core::style::Style;
use super::SeriesChart;
use crate::commands::tui::theme::Theme;
#[derive(Clone)]
pub struct Mark {
pub year: i16,
pub label: String,
pub style: Style,
}
impl Mark {
pub fn cursor(year: i16, theme: &Theme) -> Self {
Self {
year,
label: year.to_string(),
style: theme.accented(),
}
}
}
const RULE: &str = "┊";
const ZERO_RULE: &str = "┈";
const DOTS_DOWN: f64 = 4.0;
pub(super) fn draw_zero(chart: &SeriesChart, area: Rect, buf: &mut Buffer) {
let [bottom, top] = chart.y_bounds;
if bottom >= 0.0 || top <= bottom {
return;
}
let Some(graph) = chart.graph(area).filter(|graph| !graph.is_empty()) else {
return;
};
let dots = f64::from(graph.height) * DOTS_DOWN - 1.0;
let dot = (top / (top - bottom) * dots).round();
let row = graph.y + (dot / DOTS_DOWN) as u16;
for column in graph.left()..graph.right() {
if let Some(cell) = buf.cell_mut((column, row)) {
cell.set_symbol(ZERO_RULE).set_style(chart.axis);
}
}
}
pub(super) fn draw_rules(chart: &SeriesChart, area: Rect, buf: &mut Buffer) {
let Some(graph) = chart.graph(area) else {
return;
};
for mark in chart.marks.iter().rev() {
let Some(column) = chart.column_in(graph, mark.year) else {
continue;
};
for row in graph.top()..graph.bottom() {
if let Some(cell) = buf.cell_mut((column, row)) {
cell.set_symbol(RULE).set_style(mark.style);
}
}
}
}
fn middle_year(chart: &SeriesChart) -> Mark {
let year = f64::midpoint(chart.x_bounds[0], chart.x_bounds[1]).round() as i16;
Mark {
year,
label: year.to_string(),
style: chart.axis,
}
}
pub(super) fn draw_labels(chart: &SeriesChart, area: Rect, buf: &mut Buffer) {
let Some(graph) = chart.graph(area).filter(|graph| !graph.is_empty()) else {
return;
};
let last_label = chart.x_labels.last().map_or(0, |label| label.len() as u16);
let (low, high) = (graph.x + 1, graph.right().saturating_sub(last_label + 1));
let middle = middle_year(chart);
let mut taken: Vec<(u16, u16)> = Vec::new();
for mark in chart.marks.iter().chain([&middle]) {
let length = mark.label.chars().count() as u16;
let Some(column) = chart.column_in(graph, mark.year) else {
continue;
};
if chart.x_labels.contains(&mark.label) || low + length > high {
continue;
}
let start = column.saturating_sub(length / 2).clamp(low, high - length);
let end = start + length;
if taken.iter().any(|&(from, to)| start <= to && from <= end) {
continue;
}
buf.set_string(start, area.bottom() - 1, &mark.label, mark.style);
taken.push((start, end));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::tui::chart::Series;
use crate::commands::tui::session::YearCursor;
use crate::commands::tui::support::{
SIZE, cell_fg, click_year, composed_frame, frame_to_string, headless_app, overview_chart,
};
use plurimus::core::ratatui_core::layout::Position;
use plurimus::core::ratatui_core::style::Color;
use plurimus::core::ratatui_core::widgets::Widget;
use plurimus::ui::ComputedWidgetArea;
const AREA: Rect = Rect::new(1, 2, 60, 10);
fn marked(marks: &[(i16, &str)]) -> SeriesChart {
let points = (2026..=2050).map(|year| (f64::from(year), 1.0)).collect();
let series = Series {
label: "flat".to_owned(),
color: Color::Reset,
points,
};
let marks = marks.iter().map(|&(year, label)| Mark {
year,
label: label.to_owned(),
style: Style::new(),
});
let mut chart = SeriesChart::of(vec![series], Style::new());
chart.marks = marks.collect();
chart
}
fn row_of(buf: &Buffer, row: u16) -> String {
let frame = frame_to_string(buf);
frame.lines().nth(usize::from(row)).unwrap().to_owned()
}
fn drawn(chart: &SeriesChart) -> Buffer {
let mut buf = Buffer::empty(Rect::new(0, 0, 62, 13));
chart.render(AREA, &mut buf);
buf
}
#[test]
fn a_year_is_drawn_in_the_column_that_reads_back_as_it() {
let chart = marked(&[]);
for year in 2026..=2050 {
let column = chart.column_of(AREA, year).unwrap();
let read = chart.year_at(AREA, Position::new(column, AREA.y));
assert_eq!(read, Some(year));
}
assert_eq!(chart.column_of(AREA, 2025), None);
assert_eq!(chart.column_of(AREA, 2051), None);
}
#[test]
fn a_label_gives_way_to_an_earlier_one_and_keeps_its_rule() {
let chart = marked(&[(2030, "2030"), (2031, "retire 2031"), (2049, "retire 2049")]);
let buf = drawn(&chart);
let labels = row_of(&buf, AREA.bottom() - 1);
assert!(labels.contains(" 2030 "), "{labels}");
assert!(!labels.contains("2031"), "{labels}");
assert!(labels.contains(" 2038 "), "the middle year: {labels}");
assert!(labels.ends_with("retire 2049 2050 "), "shifted: {labels}");
let rules = row_of(&buf, AREA.y + 4);
assert_eq!(rules.matches(RULE).count(), 3, "{rules}");
let dropped = chart.column_of(AREA, 2031).unwrap();
assert_eq!(buf[(dropped, AREA.y + 4)].symbol(), RULE);
}
#[test]
fn a_series_below_zero_lowers_the_scale_and_rules_zero_where_it_is_drawn() {
let ends = |year| match year {
2026 => -100_000.0,
2050 => 100_000.0,
_ => 0.0,
};
let points = (2026..=2050)
.map(|year| (f64::from(year), ends(year)))
.collect();
let series = Series {
label: "across".to_owned(),
color: Color::Reset,
points,
};
let chart = SeriesChart::of(vec![series], Style::new());
assert_eq!(chart.y_labels, ["-105k", "105k"], "5% beyond each end");
let buf = drawn(&chart);
let column = chart.column_of(AREA, 2038).unwrap();
let is_braille = |c: char| ('\u{2801}'..='\u{28ff}').contains(&c);
let zero = (AREA.top()..AREA.bottom())
.find(|&row| buf[(column, row)].symbol().chars().any(is_braille))
.expect("the line at zero");
let ruled = row_of(&buf, zero);
assert!(ruled.contains(ZERO_RULE), "{}", frame_to_string(&buf));
let mut others = (AREA.top()..AREA.bottom()).filter(|&row| row != zero);
assert!(others.all(|row| !row_of(&buf, row).contains(ZERO_RULE)));
let above = marked(&[]);
assert_eq!(above.y_labels[0], "0");
assert!(!frame_to_string(&drawn(&above)).contains(ZERO_RULE));
}
#[test]
fn a_press_moves_the_accent_mark() {
let mut app = headless_app(SIZE);
app.update();
let chart = overview_chart(&mut app);
click_year(&mut app, chart, 2038);
app.update();
let year = app.world().resource::<YearCursor>().0.unwrap();
let area = app.world().get::<ComputedWidgetArea>(chart).unwrap().0;
let row = area.bottom() - 1;
let frame = composed_frame(&app);
let labels = frame.lines().nth(usize::from(row)).unwrap();
let at = labels
.find(&format!(" {year} "))
.expect("the year under the axis");
let column = labels[..at].chars().count() as u16 + 1;
let accent = Theme::terminal().accented().fg;
assert_eq!(cell_fg(&app, column, row), accent, "{year}: {frame}");
}
}