mod annotations;
mod autogrid;
mod bounds;
mod labels;
mod overlay;
mod thresholds;
use annotations::{active_cluster, render_annotation_clusters, terminal_clusters};
use autogrid::{build_autogrid_datasets, calculate_time_grid_ticks, calculate_value_grid_ticks};
use labels::{
PlotBounds, YLabelArea, YLabelContext, render_autogrid_time_labels,
render_intermediate_y_labels, y_label_width,
};
use overlay::{merge_overlay_buffer, merge_overlay_buffer_preserving_data};
use thresholds::{prepare_thresholds, render_raw_threshold_lines, threshold_marker};
use crate::annotations::format_cluster_detail_lines;
use crate::app::{AppState, PanelState};
use crate::ui::format::{format_axis_time, get_hash_color};
use ratatui::{
prelude::*,
widgets::{Axis, Chart, Dataset, GraphType, Paragraph, Wrap},
};
use std::collections::HashMap;
pub(crate) use bounds::calculate_y_bounds;
fn graph_type_for_draw_style(draw_style: crate::app::GraphDrawStyle) -> GraphType {
match draw_style {
crate::app::GraphDrawStyle::Line => GraphType::Line,
crate::app::GraphDrawStyle::Points => GraphType::Scatter,
crate::app::GraphDrawStyle::Bars => GraphType::Bar,
}
}
fn should_overlay_points(options: &crate::app::GraphOptions) -> bool {
options.show_points == crate::app::GraphPointMode::Always
&& options.draw_style != crate::app::GraphDrawStyle::Points
}
fn area_fill_baseline(y_bounds: [f64; 2]) -> f64 {
if y_bounds[0] <= 0.0 && y_bounds[1] >= 0.0 {
0.0
} else {
y_bounds[0]
}
}
fn is_y_axis_hidden(options: &crate::app::GraphOptions) -> bool {
options.axis_placement == crate::app::GraphAxisPlacement::Hidden
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum StrongDataMaskMode {
AreaFillOnly,
AllDrawStyles,
}
fn strong_data_mask_mode(
annotation_events: &[&crate::annotations::AnnotationEvent],
) -> StrongDataMaskMode {
if annotation_events.is_empty() {
StrongDataMaskMode::AreaFillOnly
} else {
StrongDataMaskMode::AllDrawStyles
}
}
fn chart_plot_left(
chart_area: Rect,
y_label_width: u16,
x_labels: &[Span<'_>],
has_y_axis_labels: bool,
) -> u16 {
let first_x_label_width = x_labels
.first()
.map(|label| label.width() as u16)
.unwrap_or_default();
let y_axis_offset = u16::from(has_y_axis_labels);
let x_label_gutter = first_x_label_width.saturating_sub(y_axis_offset);
let labels_left_of_y_axis = if has_y_axis_labels {
y_label_width.max(x_label_gutter)
} else {
x_label_gutter
}
.min(chart_area.width / 3);
let gutter = labels_left_of_y_axis + y_axis_offset;
chart_area.left() + gutter
}
fn chart_y_label_width(labels: &[Span<'_>]) -> u16 {
labels
.iter()
.map(|label| label.width() as u16)
.max()
.unwrap_or_default()
}
fn point_to_braille_cell(
x: f64,
y: f64,
x_bounds: [f64; 2],
y_bounds: [f64; 2],
plot: PlotBounds,
) -> Option<(u16, u16)> {
if !x.is_finite()
|| !y.is_finite()
|| !x_bounds[0].is_finite()
|| !x_bounds[1].is_finite()
|| !y_bounds[0].is_finite()
|| !y_bounds[1].is_finite()
|| x < x_bounds[0]
|| x > x_bounds[1]
|| y < y_bounds[0]
|| y > y_bounds[1]
|| x_bounds[1] <= x_bounds[0]
|| y_bounds[1] <= y_bounds[0]
|| plot.right <= plot.left
|| plot.bottom < plot.top
{
return None;
}
let plot_width = plot.right.saturating_sub(plot.left);
let plot_height = plot.bottom.saturating_sub(plot.top).saturating_add(1);
let x_resolution = f64::from(plot_width) * 2.0;
let y_resolution = f64::from(plot_height) * 4.0;
let braille_x =
((x - x_bounds[0]) * (x_resolution - 1.0) / (x_bounds[1] - x_bounds[0])).round() as u16;
let braille_y =
((y_bounds[1] - y) * (y_resolution - 1.0) / (y_bounds[1] - y_bounds[0])).round() as u16;
let cell_x = plot.left.saturating_add(braille_x / 2);
let cell_y = plot.top.saturating_add(braille_y / 4);
(cell_x < plot.right && cell_y <= plot.bottom).then_some((cell_x, cell_y))
}
fn render_forced_point_markers(
frame: &mut Frame,
markers: &[(f64, f64, Color)],
x_bounds: [f64; 2],
y_bounds: [f64; 2],
plot: PlotBounds,
) {
let buf = frame.buffer_mut();
for (x, y, color) in markers {
let Some((cell_x, cell_y)) = point_to_braille_cell(*x, *y, x_bounds, y_bounds, plot) else {
continue;
};
if let Some(cell) = buf.cell_mut((cell_x, cell_y)) {
cell.set_symbol(ratatui::symbols::DOT)
.set_style(Style::default().fg(*color));
}
}
}
pub(super) fn render_graph_panel(
frame: &mut Frame,
area: Rect,
panel_index: usize,
p: &PanelState,
app: &AppState,
cursor_x: Option<f64>,
is_selected: bool,
) -> Option<Vec<crate::annotations::AnnotationEvent>> {
let theme = &app.theme;
let use_hash_colors = p.series.len() > theme.palette.len();
let (start, now) = app.time_bounds();
let annotation_events = if p.panel_type == crate::app::PanelType::Graph {
app.annotations.events_for_panel(
crate::annotations::AnnotationPanelContext {
index: panel_index,
title: &p.title,
},
[start, now],
)
} else {
Vec::new()
};
let strong_data_mask_mode = strong_data_mask_mode(&annotation_events);
let cursor_values: HashMap<String, f64> = if let Some(cx) = cursor_x {
p.series
.iter()
.filter_map(|s| {
let closest = s.points.iter().min_by(|a, b| {
let da = (a.0 - cx).abs();
let db = (b.0 - cx).abs();
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
});
if let Some((ts, val)) = closest {
if (ts - cx).abs() <= app.step.as_secs_f64() * 2.0 {
Some((s.name.clone(), *val))
} else {
None
}
} else {
None
}
})
.collect()
} else {
HashMap::new()
};
let legend_height =
if (!p.series.is_empty() || !annotation_events.is_empty()) && area.height > 5 {
2
} else {
0
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(legend_height)])
.split(area);
let chart_area = chunks[0];
let legend_area = chunks[1];
let y_bounds = calculate_y_bounds(p);
let show_autogrid = app.autogrid_enabled && p.autogrid.unwrap_or(true);
let graph_options = p.graph_options();
let hide_y_axis = is_y_axis_hidden(&graph_options);
let mut chart_datasets = Vec::new();
let mut strong_data_datasets = Vec::new();
let mut legend_items = Vec::new();
let mut forced_point_markers = Vec::new();
let mut cursor_dataset = vec![];
let threshold_data = prepare_thresholds(p, &app.threshold_marker, [start, now]);
let mut threshold_overlay_datasets = Vec::new();
if !app.threshold_marker.ends_with("line") {
let (marker, graph_type) = threshold_marker(&app.threshold_marker);
for (i, (_, color)) in threshold_data.labels.iter().enumerate() {
threshold_overlay_datasets.push(
Dataset::default()
.name("")
.marker(marker)
.graph_type(graph_type)
.style(Style::default().fg(*color))
.data(&threshold_data.datasets[i]),
);
}
}
for (i, s) in p.series.iter().enumerate() {
let color = if use_hash_colors {
get_hash_color(&s.name)
} else {
theme.palette[i % theme.palette.len()]
};
let data = if s.visible { s.points.as_slice() } else { &[] };
let mut name = s.name.clone();
if let Some(val) = cursor_values.get(&s.name) {
name.push_str(&format!(" ({})", p.display.format_number(*val)));
} else if let Some(val) = s.value {
name.push_str(&format!(" ({})", p.display.format_number(val)));
}
if name.is_empty() {
name = format!("Series {}", i);
}
legend_items.push(Span::styled("■ ".to_string(), Style::default().fg(color)));
legend_items.push(Span::styled(
format!("{} ", name),
Style::default().fg(theme.text),
));
let mut dataset = Dataset::default()
.name("")
.marker(ratatui::symbols::Marker::Braille)
.graph_type(graph_type_for_draw_style(graph_options.draw_style))
.style(Style::default().fg(color))
.data(data);
let is_area_filled = graph_options.fill_opacity.unwrap_or(0) > 0
&& graph_options.draw_style == crate::app::GraphDrawStyle::Line;
if is_area_filled || strong_data_mask_mode == StrongDataMaskMode::AllDrawStyles {
strong_data_datasets.push(
Dataset::default()
.name("")
.marker(ratatui::symbols::Marker::Braille)
.graph_type(graph_type_for_draw_style(graph_options.draw_style))
.style(Style::default().fg(color))
.data(data),
);
}
if is_area_filled {
dataset = dataset
.graph_type(GraphType::Area)
.fill_to_y(area_fill_baseline(y_bounds));
}
chart_datasets.push(dataset);
if should_overlay_points(&graph_options) {
forced_point_markers.extend(data.iter().map(|(x, y)| (*x, *y, color)));
}
}
if let Some(cx) = cursor_x {
cursor_dataset.push((cx, y_bounds[0]));
cursor_dataset.push((cx, y_bounds[1]));
chart_datasets.push(
Dataset::default()
.name("")
.marker(ratatui::symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(Color::White))
.data(&cursor_dataset),
);
if !strong_data_datasets.is_empty()
|| strong_data_mask_mode == StrongDataMaskMode::AllDrawStyles
{
strong_data_datasets.push(
Dataset::default()
.name("")
.marker(ratatui::symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(Color::White))
.data(&cursor_dataset),
);
}
}
let time_range_secs = now - start;
let x_labels = vec![
Span::styled(
format_axis_time(start, time_range_secs),
Style::default().fg(theme.text),
),
Span::styled(
format_axis_time(now, time_range_secs),
Style::default().fg(theme.text),
),
];
let chart_bottom = chart_area.bottom().saturating_sub(2); let chart_top = chart_area.top();
let plot_height = chart_bottom.saturating_sub(chart_top).saturating_add(1);
let y_axis_height = usize::from(plot_height).max(2);
let mut y_labels = vec![Span::raw(""); y_axis_height];
let autogrid_value_ticks = if show_autogrid {
calculate_value_grid_ticks(y_bounds, plot_height)
} else {
Vec::new()
};
if !hide_y_axis {
y_labels[0] = Span::styled(
p.display.format_number(y_bounds[0]),
Style::default().fg(theme.text),
);
y_labels[y_axis_height - 1] = Span::styled(
p.display.format_number(y_bounds[1]),
Style::default().fg(theme.text),
);
}
let y_max_width = if hide_y_axis {
0
} else {
y_label_width(
&y_labels,
&autogrid_value_ticks,
&threshold_data.labels,
&p.display,
)
};
let chart_y_labels = if hide_y_axis {
Vec::new()
} else {
y_labels.clone()
};
let chart_y_label_width = chart_y_label_width(&chart_y_labels);
let chart = Chart::new(chart_datasets)
.x_axis(
Axis::default()
.bounds([start, now])
.labels(x_labels.clone())
.style(Style::default().fg(theme.text)),
)
.y_axis(
Axis::default()
.style(Style::default().fg(Color::Gray))
.bounds(y_bounds)
.labels(chart_y_labels.clone()),
);
frame.render_widget(chart, chart_area);
let strong_data_buf = if strong_data_datasets.is_empty() {
None
} else {
let mut strong_data_buf = ratatui::buffer::Buffer::empty(chart_area);
let strong_data_chart = Chart::new(strong_data_datasets)
.x_axis(
Axis::default()
.bounds([start, now])
.labels(x_labels.clone())
.style(Style::default().fg(theme.text)),
)
.y_axis(
Axis::default()
.style(Style::default().fg(Color::Gray))
.bounds(y_bounds)
.labels(chart_y_labels.clone()),
);
strong_data_chart.render(chart_area, &mut strong_data_buf);
Some(strong_data_buf)
};
let chart_left = chart_plot_left(
chart_area,
chart_y_label_width,
&x_labels,
!chart_y_labels.is_empty(),
);
let chart_right = chart_area.right();
let plot_bounds = PlotBounds {
left: chart_left,
right: chart_right,
top: chart_top,
bottom: chart_bottom,
};
let annotation_clusters = terminal_clusters(annotation_events, [start, now], plot_bounds);
let active_annotation =
active_cluster(&annotation_clusters, cursor_x, [start, now], plot_bounds);
let rendered_cluster = if is_selected {
active_annotation.map(|cluster| {
cluster
.events
.iter()
.map(|event| (*event).clone())
.collect::<Vec<_>>()
})
} else {
None
};
if !threshold_overlay_datasets.is_empty() && chart_top <= chart_bottom {
let threshold_chart = Chart::new(threshold_overlay_datasets)
.x_axis(
Axis::default()
.bounds([start, now])
.labels(x_labels.clone())
.style(Style::default().fg(theme.text)),
)
.y_axis(
Axis::default()
.style(Style::default().fg(Color::Gray))
.bounds(y_bounds)
.labels(chart_y_labels.clone()),
);
let mut threshold_buf = ratatui::buffer::Buffer::empty(chart_area);
threshold_chart.render(chart_area, &mut threshold_buf);
if let Some(strong_data_buf) = strong_data_buf.as_ref() {
merge_overlay_buffer_preserving_data(
frame,
&threshold_buf,
strong_data_buf,
plot_bounds,
);
} else {
merge_overlay_buffer(frame, &threshold_buf, plot_bounds);
}
}
render_raw_threshold_lines(
frame,
&app.threshold_marker,
&threshold_data.labels,
y_bounds,
plot_bounds,
strong_data_buf.as_ref(),
);
if show_autogrid && chart_top <= chart_bottom {
let plot_width = chart_right.saturating_sub(chart_left);
let autogrid_time_ticks = calculate_time_grid_ticks(start, now, plot_width);
let autogrid_datasets = build_autogrid_datasets(
[start, now],
y_bounds,
&autogrid_time_ticks,
&autogrid_value_ticks,
plot_width,
plot_height,
);
let autogrid_overlay_datasets: Vec<_> = autogrid_datasets
.iter()
.map(|dataset| {
Dataset::default()
.name("")
.marker(ratatui::symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(app.autogrid_color))
.data(dataset)
})
.collect();
let autogrid_chart = Chart::new(autogrid_overlay_datasets)
.x_axis(
Axis::default()
.bounds([start, now])
.labels(x_labels)
.style(Style::default().fg(theme.text)),
)
.y_axis(
Axis::default()
.style(Style::default().fg(Color::Gray))
.bounds(y_bounds)
.labels(chart_y_labels),
);
let mut autogrid_buf = ratatui::buffer::Buffer::empty(chart_area);
autogrid_chart.render(chart_area, &mut autogrid_buf);
merge_overlay_buffer(frame, &autogrid_buf, plot_bounds);
render_autogrid_time_labels(
frame,
plot_bounds,
[start, now],
&autogrid_time_ticks,
time_range_secs,
app.autogrid_color,
);
}
if !hide_y_axis {
render_intermediate_y_labels(
frame,
YLabelArea {
left: chart_area.left(),
width: y_max_width,
},
plot_bounds,
YLabelContext {
y_bounds,
autogrid_ticks: &autogrid_value_ticks,
threshold_labels: &threshold_data.labels,
display: &p.display,
color: app.autogrid_color,
},
);
}
render_annotation_clusters(
frame,
&annotation_clusters,
plot_bounds,
strong_data_buf.as_ref(),
theme.border_selected,
);
render_forced_point_markers(
frame,
&forced_point_markers,
[start, now],
y_bounds,
plot_bounds,
);
if legend_height > 0 {
if let Some(cluster) = active_annotation {
let [heading, details] =
format_cluster_detail_lines(cluster, usize::from(legend_area.width));
let detail_style = Style::default().fg(theme.border_selected);
let annotation_detail = Paragraph::new(vec![
Line::styled(heading, detail_style),
Line::styled(details, detail_style),
]);
frame.render_widget(annotation_detail, legend_area);
} else {
let legend = Paragraph::new(Line::from(legend_items)).wrap(Wrap { trim: true });
frame.render_widget(legend, legend_area);
}
}
rendered_cluster
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::{
GraphAxisPlacement, GraphDrawStyle, GraphOptions, GraphPointMode, GraphStackingMode,
PanelOptions, PanelType, QueryMode, SeriesView, YAxisMode,
};
use crate::export::ExportOptions;
use crate::theme::Theme;
use ratatui::{Terminal, backend::TestBackend};
use std::time::Duration;
#[test]
fn test_graph_type_for_draw_style() {
assert_eq!(
graph_type_for_draw_style(GraphDrawStyle::Line),
GraphType::Line
);
assert_eq!(
graph_type_for_draw_style(GraphDrawStyle::Points),
GraphType::Scatter
);
assert_eq!(
graph_type_for_draw_style(GraphDrawStyle::Bars),
GraphType::Bar
);
}
#[test]
fn test_should_overlay_points() {
let mut options = GraphOptions {
draw_style: GraphDrawStyle::Line,
show_points: GraphPointMode::Auto,
..GraphOptions::default()
};
assert!(!should_overlay_points(&options));
options.show_points = GraphPointMode::Always;
assert!(should_overlay_points(&options));
options.draw_style = GraphDrawStyle::Points;
assert!(!should_overlay_points(&options));
options.draw_style = GraphDrawStyle::Bars;
options.show_points = GraphPointMode::Always;
assert!(should_overlay_points(&options));
}
#[test]
fn test_area_fill_baseline_prefers_zero_when_visible() {
assert_eq!(area_fill_baseline([-10.0, 20.0]), 0.0);
assert_eq!(area_fill_baseline([5.0, 20.0]), 5.0);
assert_eq!(area_fill_baseline([-20.0, -5.0]), -20.0);
}
#[test]
fn test_hidden_axis_flag() {
let visible = GraphOptions::default();
assert!(!is_y_axis_hidden(&visible));
let hidden = GraphOptions {
axis_placement: GraphAxisPlacement::Hidden,
draw_style: GraphDrawStyle::Line,
show_points: GraphPointMode::Auto,
fill_opacity: None,
line_interpolation: Some("smooth".to_string()),
stacking: GraphStackingMode::Normal,
};
assert!(is_y_axis_hidden(&hidden));
}
#[test]
fn comprehensive_strong_data_mask_requires_visible_routed_annotations() {
let mut disabled = area_fill_app(area_fill_panel());
disabled.annotations = crate::annotations::AnnotationState::from_path(None);
let disabled_events = routed_annotation_events(&disabled);
assert!(disabled_events.is_empty());
assert_eq!(
strong_data_mask_mode(&disabled_events),
StrongDataMaskMode::AreaFillOnly
);
let mut hidden = area_fill_app(area_fill_panel());
hidden.annotations = crate::annotations::AnnotationState::from_events_for_test(vec![
crate::annotations::test_event_at(50.0, "hidden"),
]);
hidden.annotations.toggle_visibility();
let hidden_events = routed_annotation_events(&hidden);
assert!(hidden_events.is_empty());
assert_eq!(
strong_data_mask_mode(&hidden_events),
StrongDataMaskMode::AreaFillOnly
);
let mut outside = area_fill_app(area_fill_panel());
outside.annotations = crate::annotations::AnnotationState::from_events_for_test(vec![
crate::annotations::test_event_at(101.0, "outside"),
]);
let outside_events = routed_annotation_events(&outside);
assert!(outside_events.is_empty());
assert_eq!(
strong_data_mask_mode(&outside_events),
StrongDataMaskMode::AreaFillOnly
);
let mut visible = area_fill_app(area_fill_panel());
visible.annotations = crate::annotations::AnnotationState::from_events_for_test(vec![
crate::annotations::test_event_at(50.0, "visible"),
]);
let visible_events = routed_annotation_events(&visible);
assert_eq!(
strong_data_mask_mode(&visible_events),
StrongDataMaskMode::AllDrawStyles
);
}
#[test]
fn test_chart_plot_left_visible_axis_uses_y_label_width_when_dominant() {
let chart_area = Rect::new(10, 0, 90, 20);
let x_labels = vec![Span::raw("abc")];
assert_eq!(chart_plot_left(chart_area, 6, &x_labels, true), 17);
}
#[test]
fn test_chart_plot_left_visible_axis_uses_first_x_label_when_dominant() {
let chart_area = Rect::new(10, 0, 90, 20);
let x_labels = vec![Span::raw("long-start-label")];
assert_eq!(chart_plot_left(chart_area, 6, &x_labels, true), 26);
}
#[test]
fn test_chart_plot_left_visible_axis_clamps_gutter() {
let chart_area = Rect::new(10, 0, 30, 20);
let x_labels = vec![Span::raw("very-long-start-label")];
assert_eq!(chart_plot_left(chart_area, 2, &x_labels, true), 21);
}
#[test]
fn test_chart_plot_left_hidden_axis_uses_first_x_label_gutter() {
let chart_area = Rect::new(10, 0, 90, 20);
let x_labels = vec![Span::raw("long-start-label")];
assert_eq!(chart_plot_left(chart_area, 0, &x_labels, false), 26);
}
#[test]
fn test_chart_plot_left_hidden_axis_clamps_first_x_label_gutter() {
let chart_area = Rect::new(10, 0, 30, 20);
let x_labels = vec![Span::raw("very-long-start-label")];
assert_eq!(chart_plot_left(chart_area, 0, &x_labels, false), 20);
}
#[test]
fn test_chart_plot_left_ignores_custom_label_width() {
let chart_area = Rect::new(10, 0, 90, 20);
let x_labels = vec![Span::raw("abc")];
let chart_y_labels = vec![Span::raw("0"), Span::raw("9")];
let custom_label_width = 30;
let chart_y_label_width = chart_y_label_width(&chart_y_labels);
assert_eq!(
chart_plot_left(
chart_area,
chart_y_label_width,
&x_labels,
!chart_y_labels.is_empty(),
),
13
);
assert_ne!(
chart_plot_left(
chart_area,
custom_label_width,
&x_labels,
!chart_y_labels.is_empty(),
),
13
);
}
fn area_fill_panel() -> PanelState {
PanelState {
title: "area".to_string(),
exprs: vec![],
legends: vec![],
query_modes: vec![QueryMode::Range],
series: vec![SeriesView {
name: "filled".to_string(),
value: Some(8.0),
points: vec![(0.0, 8.0), (50.0, 8.0), (100.0, 8.0)],
visible: true,
}],
last_error: None,
last_url: None,
last_samples: 3,
grid: None,
y_axis_mode: YAxisMode::Auto,
panel_type: PanelType::Graph,
thresholds: None,
min: Some(0.0),
max: Some(10.0),
autogrid: Some(true),
display: crate::ui::DisplayFormat::default(),
options: PanelOptions::Graph(GraphOptions {
draw_style: GraphDrawStyle::Line,
show_points: GraphPointMode::Never,
fill_opacity: Some(30),
axis_placement: GraphAxisPlacement::Visible,
line_interpolation: None,
stacking: GraphStackingMode::Off,
}),
}
}
fn area_fill_app(panel: PanelState) -> AppState {
let mut app = AppState::new(
crate::prom::PromClient::new("http://localhost:9090".to_string()),
Duration::from_secs(100),
Duration::from_secs(5),
Duration::from_secs(1),
"test".to_string(),
vec![panel],
0,
Theme::default(),
"dashed-line".to_string(),
ExportOptions::default(),
);
app.view_end_ts = 100;
app.autogrid_color = Color::Red;
app
}
fn routed_annotation_events(app: &AppState) -> Vec<&crate::annotations::AnnotationEvent> {
let (start, end) = app.time_bounds();
app.annotations.events_for_panel(
crate::annotations::AnnotationPanelContext {
index: 0,
title: &app.panels[0].title,
},
[start, end],
)
}
#[test]
fn test_area_fill_keeps_precedence_over_autogrid() {
let app = area_fill_app(area_fill_panel());
let panel = &app.panels[0];
let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap();
terminal
.draw(|frame| {
render_graph_panel(frame, Rect::new(0, 0, 80, 20), 0, panel, &app, None, false);
})
.unwrap();
let y_bounds = calculate_y_bounds(panel);
let chart_area = Rect::new(0, 0, 80, 18);
let x_labels = vec![Span::raw("00:00:00"), Span::raw("00:01:40")];
let chart_y_labels = vec![Span::raw("0"), Span::raw("10")];
let plot = PlotBounds {
left: chart_plot_left(
chart_area,
chart_y_label_width(&chart_y_labels),
&x_labels,
true,
),
right: chart_area.right(),
top: chart_area.top(),
bottom: chart_area.bottom().saturating_sub(2),
};
let plot_height = plot.bottom.saturating_sub(plot.top) as f64;
let grid_ratio = (5.0 - y_bounds[0]) / (y_bounds[1] - y_bounds[0]);
let grid_y = plot
.bottom
.saturating_sub((grid_ratio * plot_height).round() as u16);
let grid_colored_cells_inside_fill = (plot.left..plot.right)
.filter(|x| {
terminal
.backend()
.buffer()
.cell((*x, grid_y))
.is_some_and(|cell| cell.style().fg == Some(Color::Red))
})
.count();
assert_eq!(grid_colored_cells_inside_fill, 0);
}
#[test]
fn test_line_forced_points_are_visible_and_use_line_marker_cells() {
let mut panel = area_fill_panel();
panel.series[0].points = vec![(25.0, 2.0), (50.0, 8.0), (75.0, 4.0)];
panel.options = PanelOptions::Graph(GraphOptions {
draw_style: GraphDrawStyle::Line,
show_points: GraphPointMode::Always,
fill_opacity: None,
axis_placement: GraphAxisPlacement::Visible,
line_interpolation: None,
stacking: GraphStackingMode::Off,
});
let app = area_fill_app(panel);
let panel = &app.panels[0];
let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap();
terminal
.draw(|frame| {
render_graph_panel(frame, Rect::new(0, 0, 80, 20), 0, panel, &app, None, false);
})
.unwrap();
let y_bounds = calculate_y_bounds(panel);
let chart_area = Rect::new(0, 0, 80, 18);
let x_labels = vec![Span::raw("00:00:00"), Span::raw("00:01:40")];
let chart_y_labels = vec![Span::raw("0"), Span::raw("10")];
let plot = PlotBounds {
left: chart_plot_left(
chart_area,
chart_y_label_width(&chart_y_labels),
&x_labels,
true,
),
right: chart_area.right(),
top: chart_area.top(),
bottom: chart_area.bottom().saturating_sub(2),
};
let expected_cells: std::collections::HashSet<_> = panel.series[0]
.points
.iter()
.filter_map(|(x, y)| point_to_braille_cell(*x, *y, [0.0, 100.0], y_bounds, plot))
.collect();
let visible_point_cells: std::collections::HashSet<_> = terminal
.backend()
.buffer()
.content()
.iter()
.enumerate()
.filter_map(|(index, cell)| {
(cell.symbol() == "•").then_some(((index % 80) as u16, (index / 80) as u16))
})
.collect();
assert!(!visible_point_cells.is_empty());
assert_eq!(visible_point_cells, expected_cells);
}
#[test]
fn targeted_annotation_renders_only_on_matching_title() {
let mut app = area_fill_app(area_fill_panel());
app.panels[0].title = "CPU".to_string();
let mut deploy = crate::annotations::test_event_at(50.0, "deploy");
deploy.target = crate::annotations::AnnotationTarget::PanelTitles(
["CPU".to_string()].into_iter().collect(),
);
app.annotations = crate::annotations::AnnotationState::from_events_for_test(vec![deploy]);
let mut memory_panel = app.panels[0].clone();
memory_panel.title = "Memory".to_string();
let mut cpu = Terminal::new(TestBackend::new(80, 20)).unwrap();
cpu.draw(|frame| {
render_graph_panel(
frame,
Rect::new(0, 0, 80, 20),
0,
&app.panels[0],
&app,
None,
false,
);
})
.unwrap();
let mut memory = Terminal::new(TestBackend::new(80, 20)).unwrap();
memory
.draw(|frame| {
render_graph_panel(
frame,
Rect::new(0, 0, 80, 20),
1,
&memory_panel,
&app,
None,
false,
);
})
.unwrap();
let marker_count = |terminal: &Terminal<TestBackend>| {
terminal
.backend()
.buffer()
.content()
.iter()
.filter(|cell| cell.symbol() == "┊")
.count()
};
assert!(marker_count(&cpu) > 0);
assert_eq!(marker_count(&memory), 0);
}
#[test]
fn unknown_panel_preserves_graph_data_but_omits_annotation_markers() {
let mut app = area_fill_app(area_fill_panel());
app.panels[0].panel_type = PanelType::Unknown;
app.annotations = crate::annotations::AnnotationState::from_events_for_test(vec![
crate::annotations::test_event_at(50.0, "deploy"),
]);
let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap();
let mut rendered_cluster = None;
terminal
.draw(|frame| {
rendered_cluster = crate::ui::panels::render_panel(
frame,
Rect::new(0, 0, 80, 20),
0,
&app.panels[0],
&app,
true,
None,
);
})
.unwrap();
let buffer = terminal.backend().buffer();
assert!(
buffer
.content()
.iter()
.any(|cell| cell.style().fg == Some(app.theme.palette[0])),
"unknown panels must retain legacy graph data rendering"
);
assert_eq!(
buffer
.content()
.iter()
.filter(|cell| cell.symbol() == "┊")
.count(),
0,
"unknown panels must not render annotation markers"
);
assert!(rendered_cluster.is_none());
}
#[test]
fn tag_filter_recalculates_same_column_count() {
let mut app = area_fill_app(area_fill_panel());
let mut deploy = crate::annotations::test_event_at(50.0, "deploy");
deploy.tags = vec!["deploy".to_string()];
let mut incident = crate::annotations::test_event_at(50.0, "incident");
incident.tags = vec!["incident".to_string()];
let mut rollback = crate::annotations::test_event_at(50.0, "rollback");
rollback.tags = vec!["deploy".to_string()];
app.annotations = crate::annotations::AnnotationState::from_events_for_test(vec![
deploy, incident, rollback,
]);
app.annotations
.set_filter(crate::annotations::TagFilter::from_selected([
"deploy".to_string()
]));
let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap();
terminal
.draw(|frame| {
render_graph_panel(
frame,
Rect::new(0, 0, 80, 20),
0,
&app.panels[0],
&app,
None,
false,
);
})
.unwrap();
let chart_area = Rect::new(0, 0, 80, 18);
let x_labels = vec![Span::raw("00:00:00"), Span::raw("00:01:40")];
let chart_y_labels = vec![Span::raw("0"), Span::raw("10")];
let plot = PlotBounds {
left: chart_plot_left(
chart_area,
chart_y_label_width(&chart_y_labels),
&x_labels,
true,
),
right: chart_area.right(),
top: chart_area.top(),
bottom: chart_area.bottom().saturating_sub(2),
};
let marker_x = labels::value_to_plot_x(50.0, [0.0, 100.0], plot).unwrap();
assert_eq!(
terminal
.backend()
.buffer()
.cell((marker_x, plot.top))
.unwrap()
.symbol(),
"2"
);
}
#[test]
fn annotation_cluster_renders_and_inspects_on_same_column() {
let mut app = area_fill_app(area_fill_panel());
app.annotations = crate::annotations::AnnotationState::from_events_for_test(vec![
crate::annotations::test_event_at(50.0, "deploy"),
crate::annotations::test_event_at(50.1, "rollback"),
crate::annotations::test_event_at(50.2, "resolved"),
]);
app.cursor_x = Some(50.0);
let mut terminal = Terminal::new(TestBackend::new(80, 20)).unwrap();
terminal
.draw(|frame| {
render_graph_panel(
frame,
Rect::new(0, 0, 80, 20),
0,
&app.panels[0],
&app,
app.cursor_x,
false,
);
})
.unwrap();
let rendered = terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect::<String>();
assert!(rendered.contains("3 events near"));
assert!(rendered.contains("deploy"));
}
#[test]
fn annotation_replaces_vertical_autogrid_but_preserves_standard_line_data() {
let mut panel = area_fill_panel();
panel.series[0].points = vec![(0.0, 8.0), (60.0, 8.0), (100.0, 8.0)];
panel.options = PanelOptions::Graph(GraphOptions {
draw_style: GraphDrawStyle::Line,
show_points: GraphPointMode::Never,
fill_opacity: None,
axis_placement: GraphAxisPlacement::Visible,
line_interpolation: None,
stacking: GraphStackingMode::Off,
});
let mut app = area_fill_app(panel);
let mut baseline = Terminal::new(TestBackend::new(80, 20)).unwrap();
baseline
.draw(|frame| {
render_graph_panel(
frame,
Rect::new(0, 0, 80, 20),
0,
&app.panels[0],
&app,
None,
false,
);
})
.unwrap();
app.annotations = crate::annotations::AnnotationState::from_events_for_test(vec![
crate::annotations::test_event_at(60.0, "deploy"),
]);
let mut annotated = Terminal::new(TestBackend::new(80, 20)).unwrap();
annotated
.draw(|frame| {
render_graph_panel(
frame,
Rect::new(0, 0, 80, 20),
0,
&app.panels[0],
&app,
None,
false,
);
})
.unwrap();
let panel = &app.panels[0];
let y_bounds = calculate_y_bounds(panel);
let chart_area = Rect::new(0, 0, 80, 18);
let x_labels = vec![Span::raw("00:00:00"), Span::raw("00:01:40")];
let chart_y_labels = vec![Span::raw("0"), Span::raw("10")];
let plot = PlotBounds {
left: chart_plot_left(
chart_area,
chart_y_label_width(&chart_y_labels),
&x_labels,
true,
),
right: chart_area.right(),
top: chart_area.top(),
bottom: chart_area.bottom().saturating_sub(2),
};
let marker_x = labels::value_to_plot_x(60.0, [0.0, 100.0], plot).unwrap();
let strong_cell = point_to_braille_cell(60.0, 8.0, [0.0, 100.0], y_bounds, plot).unwrap();
assert_eq!(
baseline
.backend()
.buffer()
.cell(strong_cell)
.unwrap()
.style()
.fg,
Some(app.theme.palette[0]),
"the collision cell must contain standard-line series data"
);
assert_eq!(
annotated
.backend()
.buffer()
.cell((marker_x, plot.top))
.unwrap()
.symbol(),
"•"
);
assert!(
(plot.top.saturating_add(1)..=plot.bottom).any(|y| {
annotated
.backend()
.buffer()
.cell((marker_x, y))
.is_some_and(|cell| cell.symbol() == "┊")
}),
"annotation marker should replace vertical autogrid cells"
);
assert_eq!(
annotated.backend().buffer().cell(strong_cell),
baseline.backend().buffer().cell(strong_cell),
"the standard line cell must remain visually dominant"
);
}
#[test]
fn annotation_details_keep_one_logical_line_per_reserved_row() {
let mut app = area_fill_app(area_fill_panel());
app.annotations = crate::annotations::AnnotationState::from_events_for_test(vec![
crate::annotations::test_event_at(50.0, "deploy"),
crate::annotations::test_event_at(50.1, "rollback"),
crate::annotations::test_event_at(50.2, "resolved"),
]);
app.cursor_x = Some(50.0);
let mut terminal = Terminal::new(TestBackend::new(30, 12)).unwrap();
terminal
.draw(|frame| {
render_graph_panel(
frame,
Rect::new(0, 0, 30, 12),
0,
&app.panels[0],
&app,
app.cursor_x,
false,
);
})
.unwrap();
let rendered = terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect::<String>();
assert!(rendered.contains("3 events near"));
assert!(rendered.contains("deploy"));
}
#[test]
fn annotation_details_are_bounded_by_terminal_legend_width() {
let mut app = area_fill_app(area_fill_panel());
app.annotations = crate::annotations::AnnotationState::from_events_for_test(
(0..100)
.map(|index| {
crate::annotations::test_event_at(
50.0,
&format!("event-{index:03}-{}", "x".repeat(80)),
)
})
.collect(),
);
app.cursor_x = Some(50.0);
let width = 36;
let height = 12;
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| {
render_graph_panel(
frame,
Rect::new(0, 0, width, height),
0,
&app.panels[0],
&app,
app.cursor_x,
false,
);
})
.unwrap();
let row_text = |y| {
(0..width)
.filter_map(|x| terminal.backend().buffer().cell((x, y)))
.map(|cell| cell.symbol())
.collect::<String>()
.trim_end()
.to_string()
};
let heading = row_text(height - 2);
let details = row_text(height - 1);
assert!(heading.starts_with("100 events near"));
assert!(heading.ends_with('…'));
assert!(details.starts_with("event-000-"));
assert!(details.ends_with('…'));
assert!(!details.contains("event-001-"));
assert!(heading.chars().count() <= usize::from(width));
assert!(details.chars().count() <= usize::from(width));
}
}