use ratatui::widgets::{Block, Borders};
use crate::backend::ratatui_backend::common::{
ClipBounds, calculate_visible_borders, style_paints_bg, to_ratatui_border_set,
to_ratatui_border_type, to_ratatui_style,
};
use std::sync::Arc;
use crate::style::resolve::{resolve_accent_style, resolve_base_style, resolve_muted_style};
use crate::style::{Rect, Theme};
use crate::utils::braille::{braille_char, line_pixels, set_pixel};
use crate::widgets::ChartSeriesMode;
use crate::widgets::internal::ChartNode;
pub(crate) fn render_chart(
f: &mut ratatui::Frame<'_>,
node: &ChartNode,
theme: &Theme,
rect: Rect,
clip_rect: Rect,
) {
if rect.w == 0 || rect.h == 0 {
return;
}
let mut node = node.clone();
node.style = resolve_base_style(theme, node.style);
node.axis_style = resolve_base_style(theme, node.axis_style);
node.grid_style = resolve_muted_style(theme, node.grid_style);
node.legend_style = resolve_base_style(theme, node.legend_style);
node.x_axis.style = resolve_base_style(theme, node.x_axis.style);
node.y_axis.style = resolve_base_style(theme, node.y_axis.style);
for series in &mut Arc::make_mut(&mut node.output).series {
series.style = resolve_accent_style(theme, series.style);
}
for threshold in &mut Arc::make_mut(&mut node.output).thresholds {
threshold.style = resolve_accent_style(theme, threshold.style);
}
let node = &node;
let mut inner = rect;
let rrect = crate::backend::ratatui_backend::common::to_ratatui_rect(rect);
if style_paints_bg(node.style) {
let clear = Block::default().style(to_ratatui_style(node.style));
f.render_widget(clear, rrect);
}
if node.border {
let borders = calculate_visible_borders(rect, Some(clip_rect));
let mut block = Block::default()
.borders(borders)
.border_type(to_ratatui_border_type(node.border_style))
.style(to_ratatui_style(node.style));
if let Some(set) = to_ratatui_border_set(node.border_style) {
block = block.border_set(set);
}
f.render_widget(block, rrect);
if borders.contains(Borders::LEFT) {
inner.x = inner.x.saturating_add(1);
inner.w = inner.w.saturating_sub(1);
}
if borders.contains(Borders::RIGHT) {
inner.w = inner.w.saturating_sub(1);
}
if borders.contains(Borders::TOP) {
inner.y = inner.y.saturating_add(1);
inner.h = inner.h.saturating_sub(1);
}
if borders.contains(Borders::BOTTOM) {
inner.h = inner.h.saturating_sub(1);
}
}
inner = inner.inset(node.padding);
if inner.w == 0 || inner.h == 0 {
return;
}
let legend_rows = if node.show_legend && !node.output.series.is_empty() {
1
} else {
0
};
let x_axis_rows = if node.x_axis.show { 1 } else { 0 };
let y_axis_cols = if node.y_axis.show { 8 } else { 0 };
let plot_rect = Rect {
x: inner.x.saturating_add(y_axis_cols as i16),
y: inner.y.saturating_add(legend_rows as i16),
w: inner.w.saturating_sub(y_axis_cols),
h: inner.h.saturating_sub(legend_rows + x_axis_rows),
};
if plot_rect.w == 0 || plot_rect.h == 0 {
return;
}
let content_clip = inner.intersection(&clip_rect);
let clip = ClipBounds::from_rect(content_clip);
let bounds = ClipBounds::from_rrect(f.area());
if legend_rows > 0 {
render_legend(f, node, inner, &clip, &bounds);
}
if node.show_grid {
render_grid(f, node, plot_rect, &clip, &bounds);
}
render_thresholds(f, node, plot_rect, &clip, &bounds);
render_series(f, node, plot_rect, &clip, &bounds);
render_axes(f, node, inner, plot_rect, &clip, &bounds);
}
fn render_legend(
f: &mut ratatui::Frame<'_>,
node: &ChartNode,
inner: Rect,
clip: &ClipBounds,
bounds: &ClipBounds,
) {
let mut x = inner.x;
let y = inner.y;
let sep = node.legend_separator.as_ref();
let series_len = node.output.series.len();
let sep_style = to_ratatui_style(node.legend_style);
for (idx, series) in node.output.series.iter().enumerate() {
let style = to_ratatui_style(node.legend_style.patch(series.style));
let marker = match series.mode {
ChartSeriesMode::Line => series.point_char,
ChartSeriesMode::Braille => 'â ’',
ChartSeriesMode::Bars => series.bar_char,
};
let label = format!("{marker} {}", series.name);
x = draw_text(f, x, y, &label, style, clip, bounds);
if idx + 1 < series_len {
x = draw_text(f, x, y, sep, sep_style, clip, bounds);
}
}
}
fn render_grid(
f: &mut ratatui::Frame<'_>,
node: &ChartNode,
plot_rect: Rect,
clip: &ClipBounds,
bounds: &ClipBounds,
) {
let ticks_x = node.x_axis.ticks.max(2) as i16;
let ticks_y = node.y_axis.ticks.max(2) as i16;
let vstyle = to_ratatui_style(node.style.patch(node.grid_style));
for i in 0..ticks_x {
let x = plot_rect
.x
.saturating_add(scale_index(i, ticks_x - 1, plot_rect.w));
for y in plot_rect.y..plot_rect.y.saturating_add(plot_rect.h as i16) {
draw_char(f, x, y, '┆', vstyle, clip, bounds);
}
}
for i in 0..ticks_y {
let y = plot_rect
.y
.saturating_add(scale_index(i, ticks_y - 1, plot_rect.h));
for x in plot_rect.x..plot_rect.x.saturating_add(plot_rect.w as i16) {
draw_char(f, x, y, '┈', vstyle, clip, bounds);
}
}
}
fn render_thresholds(
f: &mut ratatui::Frame<'_>,
node: &ChartNode,
plot_rect: Rect,
clip: &ClipBounds,
bounds: &ClipBounds,
) {
for threshold in node.output.thresholds.iter() {
let y = map_y(plot_rect, threshold.y_norm);
let style = to_ratatui_style(node.style.patch(threshold.style));
for x in plot_rect.x..plot_rect.x.saturating_add(plot_rect.w as i16) {
draw_char(f, x, y, threshold.glyph, style, clip, bounds);
}
let label = threshold
.label
.as_deref()
.map(str::to_owned)
.unwrap_or_else(|| format!("{:.2}", threshold.value));
let label_x = plot_rect
.x
.saturating_add(plot_rect.w as i16)
.saturating_sub(label.len() as i16 + 1);
let _ = draw_text(f, label_x, y, &label, style, clip, bounds);
}
}
fn render_series(
f: &mut ratatui::Frame<'_>,
node: &ChartNode,
plot_rect: Rect,
clip: &ClipBounds,
bounds: &ClipBounds,
) {
let bottom = plot_rect
.y
.saturating_add(plot_rect.h as i16)
.saturating_sub(1);
for series in node.output.series.iter() {
let style = to_ratatui_style(node.style.patch(series.style));
match series.mode {
ChartSeriesMode::Bars => {
for (x_norm, y_norm) in series.points.iter().copied() {
let x = map_x(plot_rect, x_norm);
let top = map_y(plot_rect, y_norm);
for y in top..=bottom {
draw_char(f, x, y, series.bar_char, style, clip, bounds);
}
}
}
ChartSeriesMode::Braille => {
render_braille_series(f, plot_rect, &series.points, style, clip, bounds);
}
ChartSeriesMode::Line => {
let mut prev = None;
for (x_norm, y_norm) in series.points.iter().copied() {
let x = map_x(plot_rect, x_norm);
let y = map_y(plot_rect, y_norm);
if let Some((px, py)) = prev {
draw_line(
f,
clip,
bounds,
LineDrawCtx {
x0: px,
y0: py,
x1: x,
y1: y,
ch: series.line_char,
style,
},
);
}
draw_char(f, x, y, series.point_char, style, clip, bounds);
prev = Some((x, y));
}
}
}
}
}
fn render_braille_series(
f: &mut ratatui::Frame<'_>,
plot_rect: Rect,
points: &[(f64, f64)],
style: ratatui::style::Style,
clip: &ClipBounds,
bounds: &ClipBounds,
) {
let cells = braille_cells(points, plot_rect.w, plot_rect.h);
for (index, dots) in cells.into_iter().enumerate() {
if dots == 0 {
continue;
}
let x = plot_rect
.x
.saturating_add((index % plot_rect.w as usize) as i16);
let y = plot_rect
.y
.saturating_add((index / plot_rect.w as usize) as i16);
draw_char(f, x, y, braille_char(dots), style, clip, bounds);
}
}
fn braille_cells(points: &[(f64, f64)], width: u16, height: u16) -> Vec<u8> {
let mut cells = vec![0; width as usize * height as usize];
if width == 0 || height == 0 || points.is_empty() {
return cells;
}
let sub_width = i32::from(width) * 2;
let sub_height = i32::from(height) * 4;
let map = |x_norm: f64, y_norm: f64| {
let x = (x_norm.clamp(0.0, 1.0) * f64::from(sub_width - 1)).round() as i32;
let y = ((1.0 - y_norm.clamp(0.0, 1.0)) * f64::from(sub_height - 1)).round() as i32;
(x, y)
};
let mut previous = map(points[0].0, points[0].1);
set_braille_dot(&mut cells, width, previous.0, previous.1);
for &(x_norm, y_norm) in &points[1..] {
let current = map(x_norm, y_norm);
line_pixels(previous, current, |x, y| {
set_braille_dot(&mut cells, width, x, y);
});
previous = current;
}
cells
}
fn set_braille_dot(cells: &mut [u8], width: u16, x: i32, y: i32) {
if x < 0 || y < 0 || width == 0 {
return;
}
let cell_x = x as usize / 2;
let cell_y = y as usize / 4;
let index = cell_y.saturating_mul(width as usize).saturating_add(cell_x);
let Some(cell) = cells.get_mut(index) else {
return;
};
*cell = set_pixel(*cell, (x as usize % 2) as u8, (y as usize % 4) as u8);
}
fn render_axes(
f: &mut ratatui::Frame<'_>,
node: &ChartNode,
inner: Rect,
plot_rect: Rect,
clip: &ClipBounds,
bounds: &ClipBounds,
) {
let axis_style = to_ratatui_style(node.style.patch(node.axis_style));
if node.y_axis.show {
let axis_x = plot_rect.x.saturating_sub(1);
for y in plot_rect.y..plot_rect.y.saturating_add(plot_rect.h as i16) {
draw_char(f, axis_x, y, '│', axis_style, clip, bounds);
}
let label_style =
to_ratatui_style(node.style.patch(node.axis_style).patch(node.y_axis.style));
let gutter = plot_rect.x.saturating_sub(inner.x).max(1);
let mut last_row: Option<i16> = None;
for (label, offset) in tick_positions(&node.y_axis.tick_labels, plot_rect.h) {
let y = plot_rect
.y
.saturating_add(plot_rect.h as i16)
.saturating_sub(1)
.saturating_sub(offset);
if last_row == Some(y) {
continue;
}
last_row = Some(y);
let x = inner
.x
.saturating_add(gutter)
.saturating_sub(label.chars().count() as i16);
let _ = draw_text(f, x.max(inner.x), y, label, label_style, clip, bounds);
}
if node.y_axis.tick_labels.is_empty() {
let top = format!("{:>7.2}", node.output.y_max);
let bottom = format!("{:>7.2}", node.output.y_min);
let _ = draw_text(f, inner.x, plot_rect.y, &top, label_style, clip, bounds);
let _ = draw_text(
f,
inner.x,
plot_rect
.y
.saturating_add(plot_rect.h as i16)
.saturating_sub(1),
&bottom,
label_style,
clip,
bounds,
);
}
}
if node.x_axis.show {
let y = plot_rect.y.saturating_add(plot_rect.h as i16);
for x in plot_rect.x..plot_rect.x.saturating_add(plot_rect.w as i16) {
draw_char(f, x, y, '─', axis_style, clip, bounds);
}
let label_style =
to_ratatui_style(node.style.patch(node.axis_style).patch(node.x_axis.style));
if node.x_axis.tick_labels.is_empty() {
let start = node.viewport_start;
let end = if node.output.sample_count == 0 {
start
} else {
start + node.output.sample_count.saturating_sub(1)
};
let _ = draw_text(
f,
plot_rect.x,
y,
&format!("{start}"),
label_style,
clip,
bounds,
);
let _ = draw_text(
f,
plot_rect
.x
.saturating_add(plot_rect.w as i16)
.saturating_sub(end.to_string().len() as i16),
y,
&format!("{end}"),
label_style,
clip,
bounds,
);
return;
}
let right_edge = plot_rect.x.saturating_add(plot_rect.w as i16);
let mut next_free = plot_rect.x;
for (label, offset) in tick_positions(&node.x_axis.tick_labels, plot_rect.w) {
let width = label.chars().count() as i16;
let centre = plot_rect.x.saturating_add(offset);
let x = if offset == 0 {
plot_rect.x
} else if centre >= right_edge.saturating_sub(1) {
right_edge.saturating_sub(width)
} else {
centre.saturating_sub(width / 2)
};
let x = x.clamp(
plot_rect.x,
right_edge.saturating_sub(width).max(plot_rect.x),
);
if x < next_free || x.saturating_add(width) > right_edge {
continue;
}
next_free = draw_text(f, x, y, label, label_style, clip, bounds).saturating_add(1);
}
}
}
fn tick_positions<'a>(
labels: &'a [Arc<str>],
span: u16,
) -> impl Iterator<Item = (&'a str, i16)> + 'a {
let last = labels.len().saturating_sub(1);
labels.iter().enumerate().map(move |(index, label)| {
let offset = (index * usize::from(span.saturating_sub(1)))
.checked_div(last)
.unwrap_or(0);
(label.as_ref(), offset as i16)
})
}
struct LineDrawCtx {
x0: i16,
y0: i16,
x1: i16,
y1: i16,
ch: char,
style: ratatui::style::Style,
}
fn draw_line(f: &mut ratatui::Frame<'_>, clip: &ClipBounds, bounds: &ClipBounds, ctx: LineDrawCtx) {
let LineDrawCtx {
x0,
y0,
x1,
y1,
ch,
style,
} = ctx;
let mut x = x0;
let mut y = y0;
let dx = (x1 - x0).abs();
let sx = if x0 < x1 { 1 } else { -1 };
let dy = -(y1 - y0).abs();
let sy = if y0 < y1 { 1 } else { -1 };
let mut err = dx + dy;
loop {
draw_char(f, x, y, ch, style, clip, bounds);
if x == x1 && y == y1 {
break;
}
let e2 = 2 * err;
if e2 >= dy {
err += dy;
x += sx;
}
if e2 <= dx {
err += dx;
y += sy;
}
}
}
fn draw_text(
f: &mut ratatui::Frame<'_>,
mut x: i16,
y: i16,
text: &str,
style: ratatui::style::Style,
clip: &ClipBounds,
bounds: &ClipBounds,
) -> i16 {
for ch in text.chars() {
draw_char(f, x, y, ch, style, clip, bounds);
x = x.saturating_add(1);
}
x
}
fn draw_char(
f: &mut ratatui::Frame<'_>,
x: i16,
y: i16,
ch: char,
style: ratatui::style::Style,
clip: &ClipBounds,
bounds: &ClipBounds,
) {
let x = x as i32;
let y = y as i32;
if !clip.contains(x, y) || !bounds.contains(x, y) {
return;
}
let Some(cell) = f.buffer_mut().cell_mut((x as u16, y as u16)) else {
return;
};
cell.set_char(ch).set_style(style);
}
fn map_x(plot_rect: Rect, x_norm: f64) -> i16 {
let width = plot_rect.w.saturating_sub(1) as f64;
plot_rect
.x
.saturating_add((x_norm.clamp(0.0, 1.0) * width).round() as i16)
}
fn map_y(plot_rect: Rect, y_norm: f64) -> i16 {
let height = plot_rect.h.saturating_sub(1) as f64;
let scaled = (y_norm.clamp(0.0, 1.0) * height).round() as i16;
plot_rect
.y
.saturating_add(plot_rect.h.saturating_sub(1) as i16)
.saturating_sub(scaled)
}
fn scale_index(idx: i16, max_idx: i16, span: u16) -> i16 {
if max_idx <= 0 || span <= 1 {
return 0;
}
((idx as f64 / max_idx as f64) * (span.saturating_sub(1) as f64)).round() as i16
}
#[cfg(test)]
mod tests {
use super::{braille_cells, tick_positions};
use std::sync::Arc;
fn labels(names: &[&str]) -> Vec<Arc<str>> {
names.iter().copied().map(Arc::from).collect()
}
#[test]
fn tick_positions_anchor_both_ends_of_the_span() {
let labels = labels(&["a", "b", "c"]);
let placed: Vec<_> = tick_positions(&labels, 21).collect();
assert_eq!(placed, vec![("a", 0), ("b", 10), ("c", 20)]);
}
#[test]
fn tick_positions_place_a_lone_label_at_the_low_end() {
let labels = labels(&["only"]);
let placed: Vec<_> = tick_positions(&labels, 40).collect();
assert_eq!(placed, vec![("only", 0)]);
}
#[test]
fn tick_positions_stay_inside_a_degenerate_span() {
let labels = labels(&["a", "b"]);
let placed: Vec<_> = tick_positions(&labels, 0).collect();
assert_eq!(placed, vec![("a", 0), ("b", 0)]);
}
#[test]
fn braille_line_uses_both_horizontal_subcells() {
let cells = braille_cells(&[(0.0, 0.5), (1.0, 0.5)], 3, 1);
assert_eq!(cells.len(), 3);
assert!(cells.iter().all(|dots| dots.count_ones() >= 2));
}
#[test]
fn braille_line_connects_vertical_extremes() {
let cells = braille_cells(&[(0.5, 0.0), (0.5, 1.0)], 1, 2);
assert_eq!(cells, vec![0xb8, 0xb8]);
}
}