use chrono::{Local, TimeZone};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
style::{Color, Modifier, Style},
symbols,
text::{Line, Span},
widgets::{Axis, Block, Borders, Chart, Dataset, GraphType, Paragraph, Tabs},
Frame,
};
use crate::agp;
use crate::app::{App, Field, GraphView, Screen};
use crate::bigfont;
use crate::config::GraphStyle;
use crate::stats;
pub fn draw(f: &mut Frame, app: &App) {
if app.screen == Screen::Settings {
draw_settings(f, app);
return;
}
let banner = app.alert.is_alerting();
let minimap = app.minimap_enabled;
let wide = f.area().width >= 90;
let mut constraints = Vec::new();
if banner {
constraints.push(Constraint::Length(1)); }
constraints.push(Constraint::Length(3)); if wide {
constraints.push(Constraint::Length(8)); } else {
constraints.push(Constraint::Length(8)); constraints.push(Constraint::Length(5)); }
constraints.push(Constraint::Min(8)); if minimap {
constraints.push(Constraint::Length(4)); }
constraints.push(Constraint::Length(1));
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(f.area());
let mut i = 0;
if banner {
draw_banner(f, chunks[i], app);
i += 1;
}
draw_header(f, chunks[i], app);
i += 1;
if wide {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(chunks[i]);
draw_current(f, cols[0], app);
draw_stats(f, cols[1], app);
i += 1;
} else {
draw_current(f, chunks[i], app);
draw_stats(f, chunks[i + 1], app);
i += 2;
}
draw_graph_pane(f, chunks[i], app);
i += 1;
if minimap {
draw_minimap(f, chunks[i], app);
i += 1;
}
draw_footer(f, chunks[i], app);
}
fn draw_minimap(f: &mut Frame, area: Rect, app: &App) {
let hours = app.minimap_span_ms / 3_600_000;
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" {hours}h overview "));
let inner = block.inner(area);
f.render_widget(block, area);
app.minimap_rect.set(Some(inner));
let now = chrono::Utc::now().timestamp_millis();
let start = now - app.minimap_span_ms;
if app.minimap_entries.is_empty() {
return;
}
let points: Vec<(f64, f64)> = app
.minimap_entries
.iter()
.rev()
.map(|e| (e.date as f64, app.units.from_mgdl(e.sgv)))
.collect();
let (min_y, max_y) = points
.iter()
.fold((f64::MAX, f64::MIN), |(lo, hi), (_, y)| {
(lo.min(*y), hi.max(*y))
});
let bounds_y = [min_y, max_y.max(min_y + 1.0)];
let vs = (app.view_start.max(start)) as f64;
let ve = (app.view_end.min(now)) as f64;
let start_rule = [(vs, bounds_y[0]), (vs, bounds_y[1])];
let end_rule = [(ve, bounds_y[0]), (ve, bounds_y[1])];
let datasets = vec![
Dataset::default()
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(Color::DarkGray))
.data(&points),
Dataset::default()
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(app.theme.graph))
.data(&start_rule),
Dataset::default()
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(app.theme.graph))
.data(&end_rule),
];
let chart = Chart::new(datasets)
.x_axis(Axis::default().bounds([start as f64, now as f64]))
.y_axis(Axis::default().bounds(bounds_y));
f.render_widget(chart, inner);
}
fn draw_stats(f: &mut Frame, area: Rect, app: &App) {
let block = Block::default().borders(Borders::ALL).title(" stats ");
let inner = block.inner(area);
f.render_widget(block, area);
let u = app.units;
let tir_line = match stats::tir(&app.entries, app.alerts.low, app.alerts.high) {
Some(t) => {
let bar_w = (inner.width as usize).saturating_sub(22).clamp(8, 40);
let rc = (t.low / 100.0 * bar_w as f64).round() as usize;
let yc = (t.high / 100.0 * bar_w as f64).round() as usize;
let gc = bar_w.saturating_sub(rc + yc);
Line::from(vec![
Span::raw(" TIR "),
Span::styled("█".repeat(rc), Style::default().fg(app.theme.low)),
Span::styled("█".repeat(gc), Style::default().fg(app.theme.in_range)),
Span::styled("█".repeat(yc), Style::default().fg(app.theme.high)),
Span::styled(
format!(" {:.0}% in range", t.in_range),
Style::default().fg(app.theme.in_range),
),
])
}
None => Line::from(" TIR —"),
};
let avg_line = match stats::mean_mgdl(&app.entries) {
Some(mean) => {
let mut spark: Vec<f64> = app.entries.iter().take(16).map(|e| e.sgv).collect();
spark.reverse();
Line::from(vec![
Span::raw(format!(" avg {} {} ", u.format(mean), u.label())),
Span::styled(sparkline_str(&spark), Style::default().fg(app.theme.graph)),
Span::raw(format!(" · GMI {:.1}%", stats::gmi(mean))),
])
}
None => Line::from(" avg —"),
};
let now = chrono::Utc::now().timestamp_millis();
let mut parts = Vec::new();
if let Some(iob) = app.device.iob {
parts.push(format!("IOB {iob:.1}U"));
}
if let Some(cob) = app.device.cob {
parts.push(format!("COB {cob:.0}g"));
}
if let Some(name) = &app.device.device {
parts.push(name.clone());
}
if let Some(b) = app.device.battery {
parts.push(format!("battery {b}%"));
}
if let Some(start) = app.sensor_start_ms {
parts.push(format!("sensor {}", fmt_age(now - start)));
}
if let Some(last) = app.device.last_ms {
parts.push(format!("uploader {} ago", fmt_age(now - last)));
}
let dev_line = if parts.is_empty() {
Line::from(Span::styled(
" device —",
Style::default().fg(Color::DarkGray),
))
} else {
Line::from(Span::styled(
format!(" {}", parts.join(" · ")),
Style::default().fg(Color::DarkGray),
))
};
f.render_widget(Paragraph::new(vec![tir_line, avg_line, dev_line]), inner);
}
fn sparkline_str(values: &[f64]) -> String {
const BARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
if values.is_empty() {
return String::new();
}
let (min, max) = values
.iter()
.fold((f64::MAX, f64::MIN), |(lo, hi), &v| (lo.min(v), hi.max(v)));
let range = (max - min).max(1.0);
values
.iter()
.map(|&v| {
let level = ((v - min) / range * (BARS.len() - 1) as f64).round() as usize;
BARS[level.min(BARS.len() - 1)]
})
.collect()
}
fn fmt_age(ms: i64) -> String {
let mins = ms.max(0) / 60_000;
let days = mins / 1440;
let hours = (mins % 1440) / 60;
let m = mins % 60;
if days > 0 {
format!("{days}d {hours}h")
} else if hours > 0 {
format!("{hours}h {m}m")
} else {
format!("{m}m")
}
}
fn draw_settings(f: &mut Frame, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(5), Constraint::Length(1), ])
.split(f.area());
let header = Paragraph::new(Line::from(Span::styled(
" settings ",
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
)))
.block(Block::default().borders(Borders::ALL));
f.render_widget(header, chunks[0]);
let block = Block::default().borders(Borders::ALL);
let inner = block.inner(chunks[1]);
f.render_widget(block, chunks[1]);
enum Row {
Header(&'static str),
Field(usize, Field),
}
let mut display: Vec<Row> = Vec::new();
let mut last_group = "";
for (i, &field) in Field::ALL.iter().enumerate() {
let g = field.group();
if g != last_group {
display.push(Row::Header(g));
last_group = g;
}
display.push(Row::Field(i, field));
}
let height = inner.height.max(1) as usize;
let sel_display = display
.iter()
.position(|r| matches!(r, Row::Field(i, _) if *i == app.settings_sel))
.unwrap_or(0);
let offset = if sel_display < height {
0
} else {
(sel_display + 1 - height).min(display.len().saturating_sub(height))
};
let lines: Vec<Line> = display
.iter()
.skip(offset)
.take(height)
.map(|row| match row {
Row::Header(name) => Line::from(Span::styled(
format!(" {name}"),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)),
Row::Field(i, field) => {
let selected = *i == app.settings_sel;
let marker = if selected { " ▸ " } else { " " };
let style = if selected {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
Line::from(Span::styled(
format!("{marker}{:<26}{}", field.label(), app.field_value(*field)),
style,
))
}
})
.collect();
f.render_widget(Paragraph::new(lines), inner);
let footer = match &app.status {
Some(msg) => Span::styled(format!(" {msg} "), Style::default().fg(Color::Green)),
None => Span::raw(" ↑/↓ select · ←/→ change · w save · s/esc back · q quit "),
};
f.render_widget(Paragraph::new(Line::from(footer)), chunks[2]);
}
fn draw_banner(f: &mut Frame, area: Rect, app: &App) {
let color = app.alert.color();
let line = Line::from(Span::styled(
format!(" ⚠ {} ", app.alert.label()),
Style::default()
.fg(Color::Black)
.bg(color)
.add_modifier(Modifier::BOLD),
));
f.render_widget(
Paragraph::new(line)
.style(Style::default().bg(color))
.alignment(Alignment::Center),
area,
);
}
fn draw_header(f: &mut Frame, area: Rect, app: &App) {
let mode = if app.view.is_live() {
Span::styled(" ● live ", Style::default().fg(Color::Green))
} else {
Span::styled(" ◷ history ", Style::default().fg(Color::Yellow))
};
let mut spans = vec![
Span::styled(
" sugarrush ",
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
),
Span::raw(format!(
"· {} · {} ",
app.units.label(),
app.view.span.label()
)),
mode,
];
if app.sites.len() > 1 {
spans.push(Span::styled(
format!(" [{}] ", app.active_site().name),
Style::default().fg(Color::Blue),
));
}
if !app.online {
let age = app
.last_ok_ms
.map(|t| {
format!(
" (last {} ago)",
fmt_age(chrono::Utc::now().timestamp_millis() - t)
)
})
.unwrap_or_default();
spans.push(Span::styled(
format!(" ⚠ offline — can't reach Nightscout{age} "),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
));
}
let title = Line::from(spans);
let p = Paragraph::new(title).block(Block::default().borders(Borders::ALL));
f.render_widget(p, area);
}
fn draw_current(f: &mut Frame, area: Rect, app: &App) {
let block = Block::default().borders(Borders::ALL).title(" current ");
let inner = block.inner(area);
f.render_widget(block, area);
let Some(e) = app.latest() else {
f.render_widget(Paragraph::new(" no data in this window…"), inner);
return;
};
let (content, bar_area) = if inner.height >= 6 {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(4), Constraint::Length(1)])
.split(inner);
(rows[0], Some(rows[1]))
} else {
(inner, None)
};
let value = app.units.format(e.sgv);
let color = color_for(e.sgv, app);
let info = current_info(app, e);
let big_w = bigfont::width(&value);
if content.height as usize >= bigfont::ROWS && content.width >= big_w + 24 {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(big_w + 3), Constraint::Min(0)])
.split(content);
let big: Vec<Line> = bigfont::render(&value)
.into_iter()
.map(|l| {
Line::from(Span::styled(
format!(" {l}"),
Style::default().fg(color).add_modifier(Modifier::BOLD),
))
})
.collect();
f.render_widget(Paragraph::new(big), cols[0]);
f.render_widget(Paragraph::new(info), cols[1]);
} else {
let mut lines = vec![Line::from(Span::styled(
format!(" {} {}", value, e.arrow()),
Style::default().fg(color).add_modifier(Modifier::BOLD),
))];
lines.extend(info.into_iter().skip(1)); f.render_widget(Paragraph::new(lines), content);
}
if let Some(ba) = bar_area {
f.render_widget(Paragraph::new(range_bar(app, e.sgv, ba.width)), ba);
}
}
fn range_bar<'a>(app: &App, sgv: f64, width: u16) -> Line<'a> {
let u = app.units;
let lo = u.from_mgdl(app.alerts.urgent_low);
let hi = u.from_mgdl(app.alerts.urgent_high);
let low = u.from_mgdl(app.alerts.low);
let high = u.from_mgdl(app.alerts.high);
let lo_s = format!("{lo:.1}");
let hi_s = format!("{hi:.1}");
let used = lo_s.len() + hi_s.len() + 3;
let cells = (width as usize).saturating_sub(used);
if cells < 6 {
return Line::from("");
}
let span = (hi - lo).max(0.1);
let cur = u.from_mgdl(sgv);
let marker = (((cur - lo) / span) * (cells as f64 - 1.0)).round();
let marker = marker.clamp(0.0, cells as f64 - 1.0) as usize;
let mut spans = vec![Span::styled(
format!(" {lo_s} "),
Style::default().fg(Color::DarkGray),
)];
for i in 0..cells {
if i == marker {
spans.push(Span::styled(
"●",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
));
continue;
}
let v = lo + (i as f64 / (cells as f64 - 1.0)) * span;
let c = if v < low {
app.theme.low
} else if v > high {
app.theme.high
} else {
app.theme.in_range
};
spans.push(Span::styled("━", Style::default().fg(c)));
}
spans.push(Span::styled(
format!(" {hi_s}"),
Style::default().fg(Color::DarkGray),
));
Line::from(spans)
}
fn current_info<'a>(app: &App, e: &crate::nightscout::Entry) -> Vec<Line<'a>> {
let delta = app
.delta_mgdl()
.map(|d| {
let sign = if d >= 0.0 { "+" } else { "-" };
format!("{}{}", sign, app.units.format(d.abs()))
})
.unwrap_or_else(|| "--".into());
let stamp = fmt_time(e.date);
let when = if app.view.is_live() {
format!("as of {stamp}")
} else {
format!("window end · {stamp}")
};
let range = crate::alert::from_value(e.sgv, &app.alerts).label();
let mut lines = vec![
Line::from(Span::styled(
format!(" {} {}", app.units.label(), e.arrow()),
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
format!(" {range}"),
Style::default().fg(color_for(e.sgv, app)),
)),
Line::from(format!(" Δ {} {}", delta, app.units.label())),
];
if let Some((rising, mins)) = app.prediction_eta(chrono::Utc::now().timestamp_millis()) {
let (arrow, word, c) = if rising {
("↗", "high", app.theme.high)
} else {
("↘", "low", app.theme.low)
};
lines.push(Line::from(Span::styled(
format!(" {arrow} {word} in ~{mins} min"),
Style::default().fg(c),
)));
}
lines.push(Line::from(Span::styled(
format!(" {when}"),
Style::default().fg(Color::DarkGray),
)));
lines
}
fn draw_graph_pane(f: &mut Frame, area: Rect, app: &App) {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(3)])
.split(area);
draw_graph_tabs(f, rows[0], app);
match app.graph_view {
GraphView::Agp => draw_agp(f, rows[1], app),
_ => draw_graph(f, rows[1], app),
}
}
fn draw_graph_tabs(f: &mut Frame, area: Rect, app: &App) {
let titles: Vec<Line> = GraphView::ALL
.iter()
.map(|v| Line::from(v.label()))
.collect();
let tabs = Tabs::new(titles)
.select(app.graph_view.index())
.style(Style::default().fg(Color::DarkGray))
.highlight_style(
Style::default()
.fg(app.theme.graph)
.add_modifier(Modifier::BOLD),
)
.divider(symbols::DOT);
f.render_widget(tabs, area);
}
fn draw_agp(f: &mut Frame, area: Rect, app: &App) {
let block = Block::default().borders(Borders::ALL).title(format!(
" AGP · last {}d · median + 25/75 + 5/95 ",
app.agp_days
));
let bands = agp::profile(&app.agp_entries);
if bands.is_empty() {
f.render_widget(
Paragraph::new(" gathering days of history…").block(block),
area,
);
return;
}
let conv = |mgdl: f64| app.units.from_mgdl(mgdl);
let series = |pick: fn(&agp::Band) -> f64| -> Vec<(f64, f64)> {
bands
.iter()
.map(|b| (b.minute as f64, conv(pick(b))))
.collect()
};
let p05 = series(|b| b.p05);
let p25 = series(|b| b.p25);
let p50 = series(|b| b.p50);
let p75 = series(|b| b.p75);
let p95 = series(|b| b.p95);
let low_y = conv(app.alerts.low);
let high_y = conv(app.alerts.high);
let (min_y, max_y) = p05
.iter()
.chain(p95.iter())
.fold((f64::MAX, f64::MIN), |(lo, hi), (_, y)| {
(lo.min(*y), hi.max(*y))
});
let (min_y, max_y) = (min_y.min(low_y), max_y.max(high_y));
let pad = ((max_y - min_y) * 0.1).max(conv(10.0));
let bounds_y = [min_y - pad, max_y + pad];
let bounds_x = [0.0, 1440.0];
let low_rail = [(0.0, low_y), (1440.0, low_y)];
let high_rail = [(0.0, high_y), (1440.0, high_y)];
let dim = Style::default()
.fg(app.theme.graph)
.add_modifier(Modifier::DIM);
let quart = Style::default().fg(app.theme.graph);
let median = Style::default()
.fg(app.theme.graph)
.add_modifier(Modifier::BOLD);
let datasets = vec![
braille_line(&low_rail, Style::default().fg(Color::DarkGray)),
braille_line(&high_rail, Style::default().fg(Color::DarkGray)),
braille_line(&p05, dim),
braille_line(&p95, dim),
braille_line(&p25, quart),
braille_line(&p75, quart),
braille_line(&p50, median),
];
let chart = Chart::new(datasets)
.block(block)
.x_axis(Axis::default().bounds(bounds_x).labels(vec![
Span::raw("00:00"),
Span::raw("06:00"),
Span::raw("12:00"),
Span::raw("18:00"),
Span::raw("24:00"),
]))
.y_axis(Axis::default().bounds(bounds_y).labels(vec![
Span::raw(format!("{:.1}", bounds_y[0])),
Span::raw(format!("{:.1}", bounds_y[1])),
]));
f.render_widget(chart, area);
tint_in_range_band(f, area, bounds_y, low_y, high_y);
}
fn braille_line(data: &[(f64, f64)], style: Style) -> Dataset<'_> {
Dataset::default()
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(style)
.data(data)
}
fn draw_graph(f: &mut Frame, area: Rect, app: &App) {
let title = format!(
" {} → {} ",
fmt_time(app.view_start),
fmt_time(app.view_end)
);
let block = Block::default().borders(Borders::ALL).title(title);
if app.entries.is_empty() {
f.render_widget(
Paragraph::new(" no readings in this window…")
.block(block)
.alignment(Alignment::Left),
area,
);
return;
}
let points: Vec<(f64, f64)> = app
.entries
.iter()
.rev()
.map(|e| (e.date as f64, app.units.from_mgdl(e.sgv)))
.collect();
let (mut pred_center, mut pred_low, mut pred_high) = (Vec::new(), Vec::new(), Vec::new());
if !app.predictions.is_empty() {
if let Some(e) = app.latest() {
let a = (e.date as f64, app.units.from_mgdl(e.sgv));
pred_center.push(a);
pred_low.push(a);
pred_high.push(a);
}
for p in &app.predictions {
let t = p.at_ms as f64;
let lo = app.units.from_mgdl(p.low);
let hi = app.units.from_mgdl(p.high);
pred_low.push((t, lo));
pred_high.push((t, hi));
pred_center.push((t, (lo + hi) / 2.0));
}
}
let low_y = app.units.from_mgdl(app.alerts.low);
let high_y = app.units.from_mgdl(app.alerts.high);
let (min_y, max_y) = points
.iter()
.chain(pred_low.iter())
.chain(pred_high.iter())
.fold((f64::MAX, f64::MIN), |(lo, hi), (_, y)| {
(lo.min(*y), hi.max(*y))
});
let (min_y, max_y) = (min_y.min(low_y), max_y.max(high_y));
let pad = ((max_y - min_y) * 0.1).max(app.units.from_mgdl(10.0));
let bounds_y = [min_y - pad, max_y + pad];
let right = app
.predictions
.last()
.map(|p| p.at_ms)
.unwrap_or(app.view_end)
.max(app.view_end);
let bounds_x = [app.view_start as f64, right as f64];
let mid_x = (app.view_start + right) / 2;
let now_line = app
.latest()
.map(|e| e.date as f64)
.filter(|x| *x >= app.view_start as f64 && *x <= right as f64)
.map(|x| [(x, bounds_y[0]), (x, bounds_y[1])]);
let span_y = (bounds_y[1] - bounds_y[0]).max(1.0);
let carb_pts: Vec<(f64, f64)> = app
.treatments
.iter()
.filter(|t| t.carbs.is_some())
.map(|t| (t.at_ms as f64, bounds_y[0] + span_y * 0.02))
.collect();
let bolus_pts: Vec<(f64, f64)> = app
.treatments
.iter()
.filter(|t| t.insulin.is_some())
.map(|t| (t.at_ms as f64, bounds_y[0] + span_y * 0.08))
.collect();
let (marker, gtype) = match app.graph_style {
GraphStyle::Line => (symbols::Marker::Braille, GraphType::Line),
GraphStyle::Dots => (symbols::Marker::Dot, GraphType::Scatter),
GraphStyle::Blocks => (symbols::Marker::Block, GraphType::Scatter),
};
let low_rail = [(app.view_start as f64, low_y), (right as f64, low_y)];
let high_rail = [(app.view_start as f64, high_y), (right as f64, high_y)];
let scatter = !matches!(app.graph_style, GraphStyle::Line);
let (mut low_z, mut in_z, mut high_z) = (Vec::new(), Vec::new(), Vec::new());
if scatter {
for e in app.entries.iter().rev() {
let p = (e.date as f64, app.units.from_mgdl(e.sgv));
if e.sgv < app.alerts.low {
low_z.push(p);
} else if e.sgv > app.alerts.high {
high_z.push(p);
} else {
in_z.push(p);
}
}
}
let mut datasets = vec![
Dataset::default()
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(Color::DarkGray))
.data(&low_rail),
Dataset::default()
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(Color::DarkGray))
.data(&high_rail),
];
if scatter {
for (pts, color) in [
(&in_z, app.theme.in_range),
(&low_z, app.theme.low),
(&high_z, app.theme.high),
] {
if !pts.is_empty() {
datasets.push(
Dataset::default()
.marker(marker)
.graph_type(gtype)
.style(Style::default().fg(color))
.data(pts),
);
}
}
} else {
datasets.push(
Dataset::default()
.marker(marker)
.graph_type(gtype)
.style(Style::default().fg(app.theme.graph))
.data(&points),
);
}
if let Some(nl) = &now_line {
datasets.push(
Dataset::default()
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(Color::DarkGray))
.data(nl),
);
}
if !carb_pts.is_empty() {
datasets.push(
Dataset::default()
.marker(symbols::Marker::Dot)
.graph_type(GraphType::Scatter)
.style(Style::default().fg(Color::Yellow))
.data(&carb_pts),
);
}
if !bolus_pts.is_empty() {
datasets.push(
Dataset::default()
.marker(symbols::Marker::Dot)
.graph_type(GraphType::Scatter)
.style(Style::default().fg(Color::Blue))
.data(&bolus_pts),
);
}
if !pred_center.is_empty() {
let bound = Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::DIM);
datasets.push(
Dataset::default()
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(bound)
.data(&pred_low),
);
datasets.push(
Dataset::default()
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(bound)
.data(&pred_high),
);
datasets.push(
Dataset::default()
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(app.theme.prediction))
.data(&pred_center),
);
}
let chart = Chart::new(datasets)
.block(block)
.x_axis(Axis::default().bounds(bounds_x).labels(vec![
Span::raw(fmt_time(app.view_start)),
Span::raw(fmt_time(mid_x)),
Span::raw(fmt_time(right)),
]))
.y_axis(Axis::default().bounds(bounds_y).labels(vec![
Span::raw(format!("{:.1}", bounds_y[0])),
Span::raw(format!("{:.1}", bounds_y[1])),
]));
f.render_widget(chart, area);
tint_in_range_band(f, area, bounds_y, low_y, high_y);
}
fn tint_in_range_band(f: &mut Frame, area: Rect, bounds_y: [f64; 2], low_y: f64, high_y: f64) {
let inner = area.inner(Margin::new(1, 1)); let ylab_w = format!("{:.1}", bounds_y[0])
.len()
.max(format!("{:.1}", bounds_y[1]).len()) as u16;
let plot_x = inner.x.saturating_add(ylab_w + 1);
let plot_r = inner.x + inner.width;
let plot_top = inner.y;
let plot_bot = inner.y + inner.height.saturating_sub(1); if plot_bot <= plot_top || plot_r <= plot_x {
return;
}
let ph = (plot_bot - plot_top) as f64;
let yspan = (bounds_y[1] - bounds_y[0]).max(0.001);
let row_of = |v: f64| -> u16 {
let r = ((bounds_y[1] - v) / yspan * ph).round() as i32;
(plot_top as i32 + r).clamp(plot_top as i32, plot_bot as i32) as u16
};
let (y0, y1) = (row_of(high_y), row_of(low_y));
let band = Color::Rgb(22, 46, 30);
let buf = f.buffer_mut();
for yy in y0..=y1 {
for xx in plot_x..plot_r {
if let Some(cell) = buf.cell_mut((xx, yy)) {
cell.set_bg(band);
}
}
}
}
fn draw_footer(f: &mut Frame, area: Rect, app: &App) {
if let Some(buf) = &app.date_input {
let line = Line::from(vec![
Span::styled(
" jump to date (YYYY-MM-DD): ",
Style::default().fg(Color::Cyan),
),
Span::styled(buf.clone(), Style::default().add_modifier(Modifier::BOLD)),
Span::styled("_", Style::default().add_modifier(Modifier::SLOW_BLINK)),
Span::raw(" · enter confirm · esc cancel"),
]);
f.render_widget(Paragraph::new(line), area);
return;
}
let text = match &app.last_error {
Some(err) => Span::styled(format!(" error: {err} "), Style::default().fg(Color::Red)),
None if app.perm_warning => Span::styled(
" ⚠ config.toml is readable by others — run: chmod 600 ~/.config/sugarrush/config.toml ",
Style::default().fg(Color::Yellow),
),
None => {
let mut s = if app.is_agp() {
String::from(" q quit · r refresh · u units · tab view · s settings")
} else {
String::from(
" q quit · r refresh · u units · tab view · h/l pan · +/- zoom · g date · f live · s settings",
)
};
if app.sites.len() > 1 {
s.push_str(" · n site");
}
if app.minimap_enabled {
s.push_str(" · drag overview");
}
if app.alarm_active(chrono::Utc::now().timestamp_millis()) {
s.push_str(" · a snooze");
}
s.push(' ');
Span::raw(s)
}
};
f.render_widget(Paragraph::new(Line::from(text)), area);
}
fn fmt_time(ms: i64) -> String {
match Local.timestamp_millis_opt(ms).single() {
Some(dt) => dt.format("%m-%d %H:%M").to_string(),
None => "--".into(),
}
}
fn color_for(sgv: f64, app: &App) -> Color {
let a = &app.alerts;
let t = &app.theme;
if sgv <= a.urgent_low || sgv >= a.urgent_high {
t.urgent
} else if sgv < a.low {
t.low
} else if sgv > a.high {
t.high
} else {
t.in_range
}
}