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, Clear, Dataset, GraphType, Paragraph, Tabs, Wrap},
Frame,
};
use crate::agp;
use crate::app::{App, Field, GraphView, Screen};
use crate::bigfont;
use crate::config::GraphStyle;
use crate::stats;
use crate::units::Units;
fn fmt_disp(units: Units, v: f64) -> String {
match units {
Units::Mgdl => format!("{v:.0}"),
Units::Mmol => format!("{v:.1}"),
}
}
pub fn draw(f: &mut Frame, app: &App) {
if app.screen == Screen::Followers {
draw_followers(f, app);
if app.show_help {
draw_help(f, f.area(), app.screen);
}
return;
}
if app.screen == Screen::Settings {
draw_settings(f, app);
if app.show_help {
draw_help(f, f.area(), app.screen);
}
return;
}
let banner = app.alert.is_alerting();
let wide = f.area().width >= 90;
let height = f.area().height;
let banner_h = u16::from(banner);
let base = banner_h + 20;
let full = height >= base;
let stats = wide || height >= base + 5;
let minimap = app.minimap_enabled && height >= base + if wide { 0 } else { 5 } + 4;
let compact = !full;
let mut constraints = Vec::new();
if banner {
constraints.push(Constraint::Length(1)); }
constraints.push(Constraint::Length(3)); if compact {
constraints.push(Constraint::Length(2)); } else if wide {
constraints.push(Constraint::Length(8)); } else {
constraints.push(Constraint::Length(8)); if stats {
constraints.push(Constraint::Length(5)); }
}
constraints.push(Constraint::Min(if compact { 0 } else { 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 compact {
draw_current_compact(f, chunks[i], app);
i += 1;
} else 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);
i += 1;
if stats {
draw_stats(f, chunks[i], app);
i += 1;
}
}
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);
if app.show_help {
draw_help(f, f.area(), app.screen);
}
}
fn draw_help(f: &mut Frame, area: Rect, screen: Screen) {
let settings_rows = [
("↑ / ↓ · j / k", "select a setting"),
("← / →", "change the selected setting"),
("Enter", "edit text (URL, tokens, timezone, push URL)"),
("w", "save to config.toml"),
("s / Esc", "back"),
("?", "toggle this help"),
("q", "quit"),
("Ctrl+C", "quit from anywhere"),
];
let dashboard_rows = [
("q", "quit"),
("?", "toggle this help"),
("r", "refresh now"),
("u", "toggle mg/dL ↔ mmol/L"),
("Tab / ⇧Tab", "switch graph view (3h / 24h / AGP)"),
("h / l · ← / →", "pan back / forward"),
("H / L · PgUp/Dn", "pan a whole window"),
("+ / -", "zoom window (1h–24h)"),
("g", "jump to a date"),
("[ / ]", "previous / next day"),
("End", "jump to the start of the overview"),
("f / Home / Esc", "return to live"),
("e", "export the clinical window (csv + summary)"),
("a", "snooze the audible alarm"),
("n", "switch site (multi-site)"),
("m", "follow all sites at once"),
("s", "open / close settings"),
("Ctrl+C", "quit from anywhere"),
("drag the overview", "scrub through history (mouse)"),
];
let follower_rows = [
("↑ / ↓ · j / k", "select a followed person"),
("PgUp / PgDn", "move five people"),
("Home / End", "first / last person"),
("Enter", "open selected person's dashboard"),
("a", "snooze selected person's alarm"),
("r", "refresh everyone"),
("m / Esc", "back to dashboard"),
("s", "open settings"),
("?", "toggle this help"),
("q / Ctrl+C", "quit"),
];
let rows: &[(&str, &str)] = match screen {
Screen::Settings => &settings_rows,
Screen::Followers => &follower_rows,
Screen::Dashboard => &dashboard_rows,
};
let key_w = 17usize;
let widest = rows
.iter()
.map(|(_, d)| d.chars().count())
.max()
.unwrap_or(0);
let w = ((key_w + widest + 6) as u16).min(area.width.saturating_sub(2));
let h = (rows.len() as u16 + 6).min(area.height.saturating_sub(2));
let x = area.x + (area.width.saturating_sub(w)) / 2;
let y = area.y + (area.height.saturating_sub(h)) / 2;
let popup = Rect::new(x, y, w, h);
let mut lines = vec![Line::from("")];
for (k, d) in rows.iter().copied() {
lines.push(Line::from(vec![
Span::styled(
format!(" {k:<key_w$}"),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(d),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" also: sugarrush watch · export · status (see --help)",
Style::default().fg(Color::DarkGray),
)));
lines.push(Line::from(Span::styled(
" press any key to close",
Style::default().fg(Color::DarkGray),
)));
f.render_widget(Clear, popup);
f.render_widget(
Paragraph::new(lines).block(
Block::default()
.borders(Borders::ALL)
.title(" keybindings "),
),
popup,
);
}
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() {
let msg = format!(" {}", empty_reason(app));
f.render_widget(
Paragraph::new(Span::styled(msg, Style::default().fg(Color::DarkGray))),
inner,
);
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(format!(" stats · {}d ", app.agp_days));
let inner = block.inner(area);
f.render_widget(block, area);
let u = app.units;
let tir_line = match stats::tir(
&app.agp_entries,
app.alerts.urgent_low,
app.alerts.low,
app.alerts.high,
app.alerts.urgent_high,
) {
Some(t) => {
let width = inner.width as usize;
let in_range = format!(" {:.0}% in range", t.in_range);
let mut below = if t.below() > 0.0 {
if t.very_low > 0.0 {
format!(" · {:.0}% below ({:.0}% very low)", t.below(), t.very_low)
} else {
format!(" · {:.0}% below", t.below())
}
} else {
String::new()
};
let mut above = if t.above() > 0.0 {
format!(" · {:.0}% above", t.above())
} else {
String::new()
};
const MIN_BAR: usize = 5;
let budget =
|a: &str, b: &str| 6 + in_range.len() + a.len() + b.len() + MIN_BAR > width;
if budget(&below, &above) && t.very_low > 0.0 {
below = format!(" · {:.0}% below", t.below());
}
if budget(&below, &above) {
above.clear();
}
if budget(&below, &above) {
below.clear();
}
let bar_w = width
.saturating_sub(6 + in_range.len() + below.len() + above.len())
.min(40);
let bar_w = if bar_w < MIN_BAR { 0 } else { bar_w };
let cells = |pct: f64| {
let n = (pct / 100.0 * bar_w as f64).round() as usize;
if n == 0 && pct > 0.0 {
1
} else {
n
}
};
let (vlo, lo) = (cells(t.very_low), cells(t.low));
let (vhi, hi) = (cells(t.very_high), cells(t.high));
let mid = bar_w.saturating_sub(vlo + lo + hi + vhi);
let mut spans = vec![
Span::raw(" TIR "),
Span::styled("█".repeat(vlo), Style::default().fg(app.theme.urgent)),
Span::styled("█".repeat(lo), Style::default().fg(app.theme.low)),
Span::styled("█".repeat(mid), Style::default().fg(app.theme.in_range)),
Span::styled("█".repeat(hi), Style::default().fg(app.theme.high)),
Span::styled("█".repeat(vhi), Style::default().fg(app.theme.urgent)),
Span::styled(in_range, Style::default().fg(app.theme.in_range)),
];
if !below.is_empty() {
spans.push(Span::styled(below, Style::default().fg(app.theme.low)));
}
if !above.is_empty() {
spans.push(Span::styled(above, Style::default().fg(app.theme.high)));
}
Line::from(spans)
}
None => Line::from(" TIR —"),
};
let avg_line = match stats::mean_mgdl(&app.agp_entries) {
Some(mean) => {
let head = format!(" avg {} {} ", u.format(mean), u.label());
let mut gmi = format!(" · GMI {:.1}%", stats::gmi(mean));
let mut cv = stats::cv_pct(&app.agp_entries);
let mut cv_text = cv.map(|c| format!(" · CV {c:.0}%")).unwrap_or_default();
let width = inner.width as usize;
if head.len() + gmi.len() + cv_text.len() > width {
gmi = gmi.replace(" · ", " · ");
cv_text = cv_text.replace(" · ", " · ");
}
if head.len() + gmi.len() + cv_text.len() > width {
cv = None;
cv_text.clear();
}
let room =
(inner.width as usize).saturating_sub(head.len() + gmi.len() + cv_text.len());
let mut spark: Vec<f64> = app
.entries
.iter()
.take(room.min(16))
.map(|e| e.sgv)
.collect();
spark.reverse();
let mut spans = vec![Span::raw(head)];
if spark.len() >= 4 {
spans.push(Span::styled(
sparkline_str(&spark),
Style::default().fg(app.theme.graph),
));
}
spans.push(Span::raw(gmi));
if let Some(c) = cv {
spans.push(Span::styled(
cv_text,
if c > 36.0 {
Style::default().fg(app.theme.high)
} else {
Style::default()
},
));
}
Line::from(spans)
}
None => Line::from(" avg —"),
};
let now = chrono::Utc::now().timestamp_millis();
let strong = Style::default().add_modifier(Modifier::BOLD);
let dim = Style::default().fg(Color::DarkGray);
let mut spans: Vec<Span> = Vec::new();
if let Some(iob) = app.device.iob {
spans.push(Span::styled(format!(" IOB {iob:.1}U"), strong));
}
if let Some(cob) = app.device.cob {
spans.push(Span::styled(format!(" COB {cob:.0}g"), strong));
}
let mut rest = Vec::new();
if let Some(name) = &app.device.device {
rest.push(name.clone());
}
if let Some(b) = app.device.battery {
rest.push(format!("battery {b}%"));
}
if let Some(start) = app.sensor_start_ms {
rest.push(format!("sensor {}", fmt_age(now - start)));
}
if let Some(last) = app.device.last_ms {
rest.push(format!("uploader {} ago", fmt_age(now - last)));
}
if !rest.is_empty() {
let prefix = if spans.is_empty() { " " } else { " · " };
spans.push(Span::styled(
format!("{prefix}{}", rest.join(" · ")),
dim,
));
}
let dev_line = if spans.is_empty() {
Line::from(Span::styled(" device —", dim))
} else {
Line::from(spans)
};
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 field_controls(field: Field) -> &'static str {
match field {
Field::SiteName
| Field::SiteUrl
| Field::SiteToken
| Field::SiteWriteToken
| Field::SiteTimezone
| Field::PushUrl => "Enter to edit",
Field::AddSite | Field::RemoveSite | Field::TestAlarm => "Enter to run",
_ => "← / → to change",
}
}
fn field_detail(field: Field) -> &'static str {
match field {
Field::SiteName => "The name used in follower rows, notifications, logs, and persisted alarm episodes. It must be unique.",
Field::SiteUrl => "The Nightscout base URL for the selected person. HTTPS keeps the read-only token and readings encrypted in transit.",
Field::SiteToken => "A dedicated read-only Nightscout token. It is masked here and stored in the owner-only config file.",
Field::SiteWriteToken => "Optional separate CarePortal token. Setting it crosses a security boundary: confirmed treatment commands can modify this person's Nightscout data. It is verified before every write.",
Field::SiteTimezone => "The followed person's IANA timezone (for example Europe/Amsterdam), used for AGP patterns and clinical exports. Empty means this computer's local time.",
Field::TestSite => "Fetch this Nightscout site and require a reading from the last hour. New or edited credentials cannot be saved until this passes.",
Field::AddSite => "Create another followed person without copying the current person's credential. Fill in its name, URL, and token next.",
Field::RemoveSite => "Remove the selected site from the saved list. At least one site is always retained.",
Field::SiteAlerts => "Use the global alert profile, or make a complete threshold and delivery profile for only this person.",
Field::Units => "How glucose values and editable thresholds are displayed. Internally, safety comparisons remain in mg/dL.",
Field::Refresh => "How often the dashboard asks Nightscout for new data. The watcher uses its own conservative polling policy.",
Field::Desktop => "Send a desktop notification when an alert episode starts or a predictive warning becomes due.",
Field::Osd => "Also show urgent alerts on Omarchy's on-screen display, which is drawn above fullscreen windows and is not suppressed by Do Not Disturb. Ignored on other desktops.",
Field::NotifyContent => "Choose whether notifications include the reading and state, or stay generic for shared and locked screens.",
Field::Sound => "Play the looping audible alarm for urgent and stale states. Use the test below to verify the machine can actually sound.",
Field::TestAlarm => "Play the audible half of the alarm self-test now. Run `sugarrush watch --test` for every delivery channel.",
Field::Snooze => "How long acknowledgement silences the active alarm before it can sound again.",
Field::QuietHours => "Schedule a daily period when alarms are muted. The urgent-low override below can remain armed.",
Field::QuietStart | Field::QuietEnd => "Start or end of the daily quiet window, adjusted in 30-minute steps.",
Field::QuietUrgentLow => "Keep urgent-low audio active during quiet hours as a safety override.",
Field::Escalate => "Send the configured push webhook when an urgent episode remains unacknowledged for this long.",
Field::PushAlerts => "Enable or disable the configured phone/webhook destination without discarding its URL.",
Field::PushUrl => "A phone/webhook destination. It is hidden because private topics and tokens are often embedded in the URL; enter a replacement, or `off` to clear it.",
Field::PredictHorizon => "Warn when a forecast crosses low or high within this many minutes. Zero disables predictive alerts.",
Field::UrgentLow => "At or below this value, classify the reading as urgent low and use the urgent alarm path.",
Field::Low => "Below this value, classify the reading as low after applying hysteresis on recovery.",
Field::High => "Above this value, classify the reading as high after applying hysteresis on recovery.",
Field::UrgentHigh => "At or above this value, classify the reading as urgent high and use the urgent alarm path.",
Field::Stale => "Treat the data as a sensor gap when the newest reading is older than this many minutes.",
Field::SensorDays => {
"How long your sensor is expected to last, so its age reads as time remaining. 0 turns that off."
}
Field::GraphStyle => "Draw readings as a connected line, small dots, or larger blocks.",
Field::AgpDays => "The fixed clinical window used by the AGP, time-in-range statistics, and default export.",
Field::CacheEnabled => "Opt in to an owner-only local reading cache for instant startup and outage context. Turning it off deletes every cached reading.",
Field::CacheDays => "Maximum local cache retention. Old readings are removed on every successful update; cached data is never presented as a live fetch.",
Field::MinimapEnabled => "Show the overview strip and enable mouse click/drag navigation through history.",
Field::MinimapSpan => "How much history the overview strip covers, from 6 to 72 hours.",
Field::BarArrow => "Print the trend arrow in the status-bar reading (sugarrush status, waybar, the Quickshell pill).",
Field::BarDelta => "Print the change since the previous reading in the status-bar reading.",
Field::BarUnits => "Send the unit label to bars that render one. Only the JSON output carries it; the plain, polybar, tmux and i3blocks lines never have.",
Field::BarSparkline => "Send the last hour to bars that draw a trace. JSON output only; no text format draws one.",
Field::ThemeLow => "Colour used for low readings and low-state text.",
Field::ThemeInRange => "Colour used for in-range readings and target-band cues.",
Field::ThemeHigh => "Colour used for high readings and high-state text.",
Field::ThemeUrgent => "Colour used for urgent lows, urgent highs, and safety-critical banners.",
Field::ThemePrediction => "Colour used for the forecast centre and uncertainty cone.",
Field::ThemeGraph => "Primary graph, AGP median, percentile fan, and sparkline colour.",
Field::Colorblind => "Switch the full palette to colourblind-safe colours with distinct alert roles.",
}
}
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 mut title = vec![Span::styled(
" settings ",
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
)];
if app.settings_dirty {
title.push(Span::styled(
"· unsaved changes (w to save) ",
Style::default().fg(Color::Yellow),
));
}
let header = Paragraph::new(Line::from(title)).block(Block::default().borders(Borders::ALL));
f.render_widget(header, chunks[0]);
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 split = chunks[1].width >= 80;
let panes = Layout::default()
.direction(Direction::Horizontal)
.constraints(if split {
[Constraint::Percentage(55), Constraint::Percentage(45)]
} else {
[Constraint::Percentage(100), Constraint::Percentage(0)]
})
.split(chunks[1]);
let list_area = panes[0];
let height = list_area.height.saturating_sub(2).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 above = offset > 0;
let below = offset + height < display.len();
let list_title = match (above, below) {
(true, true) => " ↑ more · fields · ↓ more ",
(true, false) => " ↑ more · fields ",
(false, true) => " fields · ↓ more ",
(false, false) => " fields ",
};
let list_block = Block::default().borders(Borders::ALL).title(list_title);
let inner = list_block.inner(list_area);
f.render_widget(list_block, list_area);
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()
};
let text = format!("{marker}{:<26}{}", field.label(), app.field_value(*field));
let text = if selected {
format!("{text:<width$}", width = inner.width as usize)
} else {
text
};
Line::from(Span::styled(text, style))
}
})
.collect();
f.render_widget(Paragraph::new(lines), inner);
if split {
let field = app.selected_field();
let detail = vec![
Line::from(vec![
Span::styled("Current ", Style::default().fg(Color::DarkGray)),
Span::styled(
app.field_value(field),
Style::default().add_modifier(Modifier::BOLD),
),
]),
Line::default(),
Line::from(field_detail(field)),
Line::default(),
Line::from(Span::styled(
field_controls(field),
Style::default().fg(Color::Cyan),
)),
];
f.render_widget(
Paragraph::new(detail).wrap(Wrap { trim: true }).block(
Block::default()
.borders(Borders::ALL)
.title(format!(" {} ", field.label())),
),
panes[1],
);
}
let mut cursor: Option<u16> = None;
let footer = match (&app.field_edit, &app.status) {
(Some(edit), _) => {
let shown = if edit.masked {
"•".repeat(edit.buffer.chars().count())
} else {
edit.buffer.clone()
};
let prompt = format!(" {}: ", edit.field.label().to_lowercase());
cursor = Some((prompt.chars().count() + shown.chars().count()) as u16);
Line::from(vec![
Span::styled(prompt, Style::default().fg(Color::Cyan)),
Span::styled(shown, Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" · enter confirm · esc cancel"),
])
}
(None, Some(msg)) => Line::from(Span::styled(
format!(" {msg} "),
Style::default().fg(Color::Green),
)),
(None, None) => Line::from(Span::raw(
" ↑/↓ select · ←/→ change · enter edit/action · w save · s/esc back · ? help · q quit ",
)),
};
f.render_widget(Paragraph::new(footer), chunks[2]);
if let Some(x) = cursor {
if x < chunks[2].width {
f.set_cursor_position((chunks[2].x + x, chunks[2].y));
}
}
}
fn draw_followers(f: &mut Frame, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(3), Constraint::Length(1), ])
.split(f.area());
let mut title = vec![Span::styled(
format!(" following {} sites ", app.sites.len()),
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
)];
if let Some(worst) = crate::follow::worst(&app.followers) {
let (text, color) = if worst.alert.is_alerting() {
(
format!("· {} needs attention: {} ", worst.name, worst.alert.label()),
app.theme.urgent,
)
} else {
("· all in range ".to_string(), app.theme.in_range)
};
title.push(Span::styled(text, Style::default().fg(color)));
}
let header = Paragraph::new(Line::from(title)).block(Block::default().borders(Borders::ALL));
f.render_widget(header, chunks[0]);
let now = chrono::Utc::now().timestamp_millis();
let available = chunks[1].height.saturating_sub(2) as usize;
let data_rows = available.saturating_sub(1);
let max_start = app.followers.len().saturating_sub(data_rows.max(1));
let start = app.follower_scroll.min(max_start);
let end = (start + data_rows).min(app.followers.len());
let rows_title = match (start > 0, end < app.followers.len()) {
(true, true) => " ↑ more · people · ↓ more ",
(true, false) => " ↑ more · people ",
(false, true) => " people · ↓ more ",
(false, false) => " people ",
};
let block = Block::default().borders(Borders::ALL).title(rows_title);
let inner = block.inner(chunks[1]);
f.render_widget(block, chunks[1]);
let lines: Vec<Line> = if app.followers.is_empty() {
vec![Line::from(format!(" {}", empty_reason(app)))]
} else {
let wide = inner.width >= 82;
let mut rows: Vec<Line> = Vec::with_capacity(app.followers.len() + 1);
rows.push(Line::from(Span::styled(
if wide {
format!(
" {:<12} {:>9} {:>7} {:<18} {:<10} {}",
"PERSON",
app.units.label(),
"DELTA",
"STATE",
"AGE",
"LAST HOUR"
)
} else {
format!(
" {:<10} {:>7} {:<13} {}",
"PERSON",
app.units.label(),
"STATE",
"TREND"
)
},
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)));
rows.extend(app.followers[start..end].iter().map(|s| {
let color = match s.alert {
crate::alert::Alert::UrgentLow | crate::alert::Alert::UrgentHigh => {
app.theme.urgent
}
crate::alert::Alert::Low => app.theme.low,
crate::alert::Alert::High => app.theme.high,
crate::alert::Alert::Stale => Color::DarkGray,
crate::alert::Alert::InRange => app.theme.in_range,
};
let age = match s.age_min(now) {
Some(m) => format!("{m}m ago"),
None if s.error.is_some() => "unavailable".into(),
None => "no data".into(),
};
let spark = sparkline_str(&s.history);
let mut spans = vec![
Span::styled("▌ ", Style::default().fg(color)),
Span::styled(
format!(
"{:<width$}",
fit_cell(&s.name, if wide { 12 } else { 10 }),
width = if wide { 12 } else { 10 }
),
Style::default().add_modifier(Modifier::BOLD),
),
Span::styled(
format!(
"{:>width$} {} ",
s.value(app.units),
s.arrow(),
width = if wide { 6 } else { 4 }
),
Style::default().fg(color).add_modifier(Modifier::BOLD),
),
];
if wide {
spans.push(Span::raw(format!("{:>7} ", s.delta_text(app.units))));
spans.push(Span::styled(
format!("{:<18}", s.alert.label()),
Style::default().fg(color),
));
spans.push(Span::styled(
format!("{age:<10}"),
Style::default().fg(Color::DarkGray),
));
} else {
spans.push(Span::styled(
format!("{:<13}", s.alert.label()),
Style::default().fg(color),
));
}
spans.push(Span::styled(spark, Style::default().fg(app.theme.graph)));
let selected = app.selected_follower() == Some(s.name.as_str());
Line::from(spans).style(if selected {
Style::default().bg(Color::DarkGray)
} else {
Style::default()
})
}));
rows
};
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 · enter open · a snooze person · m/esc back · ? help · q quit ")
}
};
f.render_widget(Paragraph::new(Line::from(footer)), chunks[2]);
}
fn fit_cell(text: &str, width: usize) -> String {
let len = text.chars().count();
if len <= width {
return text.to_string();
}
text.chars()
.take(width.saturating_sub(1))
.chain(['…'])
.collect()
}
fn draw_banner(f: &mut Frame, area: Rect, app: &App) {
use crate::alert::Alert;
let color = match app.alert {
Alert::UrgentLow | Alert::UrgentHigh => app.theme.urgent,
Alert::Low => app.theme.low,
Alert::High => app.theme.high,
Alert::Stale => Color::Magenta,
Alert::InRange => app.theme.in_range,
};
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 empty_reason(app: &App) -> &'static str {
if app.fetch_paused() {
"not loading — see the error below"
} else if !app.online() {
"no readings — can't reach Nightscout"
} else if app.last_ok_ms().is_none() {
"loading…"
} else {
"no readings in this window"
}
}
fn draw_header(f: &mut Frame, area: Rect, app: &App) {
let (dot, dot_color) = if !app.online() {
("✖", app.theme.urgent)
} else if app.alert == crate::alert::Alert::Stale {
("◌", app.theme.high)
} else {
("●", app.theme.in_range)
};
let mode = if app.view.is_live() {
Span::styled("live ", Style::default().fg(Color::DarkGray))
} 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()
)),
Span::styled(format!(" {dot} "), Style::default().fg(dot_color)),
mode,
];
if app.sites.len() > 1 {
spans.push(Span::styled(
format!(" [{}] ", app.active_site().name),
Style::default().fg(Color::Blue),
));
}
let armed = app.armed_state(chrono::Utc::now().timestamp_millis());
let mut chip = Style::default().fg(if armed.is_suppressed() {
app.theme.high
} else {
app.theme.in_range
});
if matches!(
armed,
crate::app::Armed::Off | crate::app::Armed::WatcherStopped
) {
chip = Style::default()
.fg(app.theme.urgent)
.add_modifier(Modifier::BOLD);
}
spans.push(Span::styled(armed.label(), chip));
if app.alerts.escalate_minutes > 0
&& !(app.alerts.push_url.is_some() && app.alerts.push_enabled)
{
spans.push(Span::styled(
" ⚠ escalation inactive ",
Style::default().fg(app.theme.high),
));
}
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();
let msg = app.last_error().unwrap_or("can't reach Nightscout");
spans.push(Span::styled(
format!(" ⚠ {msg}{age} "),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
));
}
spans.push(Span::styled(
" · not a medical device",
Style::default().fg(Color::DarkGray),
));
let title = Line::from(spans);
let p = Paragraph::new(title).block(Block::default().borders(Borders::ALL));
f.render_widget(p, area);
}
fn draw_current_compact(f: &mut Frame, area: Rect, app: &App) {
let Some(e) = app.latest() else {
f.render_widget(Paragraph::new(" no data in this window…"), area);
return;
};
let value = app.units.format(e.sgv);
let color = color_for(e.sgv, app);
let range = crate::alert::from_value(e.sgv, &app.alerts).label();
let delta = app
.delta_mgdl()
.map(|d| app.units.format_delta(d))
.unwrap_or_else(|| "--".into());
let mut spans = vec![
Span::styled(
format!(" {value} {} ", e.arrow()),
Style::default().fg(color).add_modifier(Modifier::BOLD),
),
Span::styled(range.to_string(), Style::default().fg(color)),
];
let rest = format!(
" · {} · Δ {delta} · {}",
app.units.label(),
fmt_time(e.date)
);
let used: usize = spans.iter().map(|s| s.content.chars().count()).sum();
if used + rest.chars().count() <= area.width as usize {
spans.push(Span::styled(rest, Style::default().fg(Color::DarkGray)));
}
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(area);
f.render_widget(Paragraph::new(Line::from(spans)), rows[0]);
if rows[1].height > 0 {
f.render_widget(
Paragraph::new(range_bar(app, e.sgv, rows[1].width)),
rows[1],
);
}
}
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(format!(" {}", empty_reason(app))), 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 range = crate::alert::from_value(e.sgv, &app.alerts).label();
let mut lines = vec![Line::from(vec![
Span::styled(
format!(" {} {}", value, e.arrow()),
Style::default().fg(color).add_modifier(Modifier::BOLD),
),
Span::styled(format!(" {range}"), Style::default().fg(color)),
])];
lines.extend(info.into_iter().skip(2));
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 lo_s = fmt_disp(u, lo);
let hi_s = fmt_disp(u, hi);
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 span_mgdl = (app.alerts.urgent_high - app.alerts.urgent_low).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 = app.alerts.urgent_low + (i as f64 / (cells as f64 - 1.0)) * span_mgdl;
spans.push(Span::styled("━", Style::default().fg(color_for(v, app))));
}
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| app.units.format_delta(d))
.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.format(e.sgv),
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 timezone = app
.active_site()
.timezone
.as_deref()
.and_then(|name| name.parse::<chrono_tz::Tz>().ok());
let bands = agp::profile_in(&app.agp_entries, timezone);
let headline = agp::insights(&bands, app.alerts.low, app.alerts.high)
.first()
.map(|i| format!("⚠ {} · ", i.text(app.units)))
.unwrap_or_default();
let title = format!(
" AGP · last {}d · target {}–{} {} · {}median + IQR + 5/95 ",
app.agp_days,
fmt_disp(app.units, app.units.from_mgdl(app.alerts.low)),
fmt_disp(app.units, app.units.from_mgdl(app.alerts.high)),
app.units.label(),
headline,
);
let block = Block::default().borders(Borders::ALL).title(title);
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 p50: Vec<(f64, f64)> = bands
.iter()
.map(|b| (b.minute as f64, conv(b.p50)))
.collect();
let low_y = conv(app.alerts.low);
let high_y = conv(app.alerts.high);
let (min_y, max_y) = bands.iter().fold((f64::MAX, f64::MIN), |(lo, hi), b| {
(lo.min(conv(b.p05)), hi.max(conv(b.p95)))
});
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 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(&p50, median),
];
let lo_lab = fmt_disp(app.units, bounds_y[0]);
let hi_lab = fmt_disp(app.units, bounds_y[1]);
let gutter = chart_gutter(&[&lo_lab, &hi_lab], "00:00");
let plot_w = area.width.saturating_sub(gutter + 3) as usize;
let x_labels = fit_labels(
plot_w,
["00:00", "06:00", "12:00", "18:00", "24:00"]
.iter()
.map(|s| s.to_string())
.collect(),
);
let chart = Chart::new(datasets)
.block(block)
.x_axis(
Axis::default()
.bounds(bounds_x)
.labels(x_labels.into_iter().map(Span::raw).collect::<Vec<_>>()),
)
.y_axis(
Axis::default()
.bounds(bounds_y)
.labels(vec![Span::raw(lo_lab), Span::raw(hi_lab)]),
);
f.render_widget(chart, area);
tint_in_range_band(f, area, bounds_y, gutter, low_y, high_y, app.theme.in_range);
tint_agp_fan(f, area, bounds_y, gutter, &bands, &conv, app.theme.graph);
label_agp_rails(f, area, bounds_y, gutter, low_y, high_y, app);
}
fn label_agp_rails(
f: &mut Frame,
area: Rect,
bounds_y: [f64; 2],
gutter: u16,
low_y: f64,
high_y: f64,
app: &App,
) {
let Some(plot) = Plot::new(area, bounds_y, gutter) else {
return;
};
for (value, row, color, name) in [
(low_y, plot.row_of(low_y), app.theme.low, "low"),
(high_y, plot.row_of(high_y), app.theme.high, "high"),
] {
let label = format!(" {name} {} ", fmt_disp(app.units, value));
let width = label.chars().count() as u16;
let x = plot.x1.saturating_sub(width + 1).max(plot.x0);
for (offset, ch) in label.chars().enumerate() {
if let Some(cell) = f.buffer_mut().cell_mut((x + offset as u16, row)) {
cell.set_char(ch)
.set_style(Style::default().fg(color).add_modifier(Modifier::BOLD));
}
}
}
}
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 mut title = vec![Span::raw(format!(
" {} → {} ",
fmt_time(app.view_start),
fmt_time(app.view_end)
))];
if app.treatments.iter().any(|t| t.carbs.is_some()) {
title.push(Span::styled(
"· ● carbs ",
Style::default().fg(Color::Yellow),
));
}
if app.treatments.iter().any(|t| t.insulin.is_some()) {
title.push(Span::styled("· ● bolus ", Style::default().fg(Color::Blue)));
}
let block = Block::default()
.borders(Borders::ALL)
.title(Line::from(title));
if app.entries.is_empty() {
f.render_widget(
Paragraph::new(format!(" {}", empty_reason(app)))
.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 let (Some(e), Some(first)) = (app.latest(), app.predictions.first()) {
let anchor_y = app.units.from_mgdl(e.sgv);
let first_mid = app.units.from_mgdl((first.low + first.high) / 2.0);
let shift = anchor_y - first_mid;
let n = app.predictions.len();
let a = (e.date as f64, anchor_y);
pred_center.push(a);
pred_low.push(a);
pred_high.push(a);
for (j, p) in app.predictions.iter().enumerate() {
let decay = if n > 1 {
(n - 1 - j) as f64 / (n - 1) as f64
} else {
0.0
};
let s = shift * decay;
let t = p.at_ms as f64;
let lo = app.units.from_mgdl(p.low) + s;
let hi = app.units.from_mgdl(p.high) + s;
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() {
datasets.push(
Dataset::default()
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(app.theme.prediction))
.data(&pred_center),
);
}
let lo_lab = fmt_disp(app.units, bounds_y[0]);
let hi_lab = fmt_disp(app.units, bounds_y[1]);
let first_x = fmt_time(app.view_start);
let gutter = chart_gutter(&[&lo_lab, &hi_lab], &first_x);
let plot_w = area
.width
.saturating_sub(gutter + 3) as usize;
let stamps = [app.view_start, mid_x, right];
let full: Vec<String> = stamps.iter().map(|t| fmt_time(*t)).collect();
let x_labels = if fit_labels(plot_w, full.clone()).len() == full.len() {
full
} else {
fit_labels(plot_w, stamps.iter().map(|t| fmt_clock(*t)).collect())
};
let chart = Chart::new(datasets)
.block(block)
.x_axis(
Axis::default()
.bounds(bounds_x)
.labels(x_labels.into_iter().map(Span::raw).collect::<Vec<_>>()),
)
.y_axis(
Axis::default()
.bounds(bounds_y)
.labels(vec![Span::raw(lo_lab), Span::raw(hi_lab)]),
);
f.render_widget(chart, area);
tint_in_range_band(f, area, bounds_y, gutter, low_y, high_y, app.theme.in_range);
if pred_low.len() > 1 {
tint_band(
f,
area,
(bounds_x, bounds_y),
gutter,
&pred_low,
&pred_high,
tint_bg(app.theme.prediction, 0.32),
);
}
}
fn rgb_of(c: Color) -> (u8, u8, u8) {
match c {
Color::Rgb(r, g, b) => (r, g, b),
Color::Red => (205, 60, 55),
Color::LightRed => (240, 100, 95),
Color::Green => (40, 170, 95),
Color::LightGreen => (90, 220, 130),
Color::Yellow => (200, 170, 40),
Color::LightYellow => (235, 215, 90),
Color::Blue => (60, 110, 210),
Color::LightBlue => (110, 170, 235),
Color::Magenta => (185, 90, 175),
Color::LightMagenta => (225, 130, 215),
Color::Cyan => (40, 175, 180),
Color::LightCyan => (110, 220, 225),
Color::White | Color::Gray => (200, 205, 210),
_ => (150, 155, 160),
}
}
fn truecolor() -> bool {
std::env::var("COLORTERM")
.map(|v| v.eq_ignore_ascii_case("truecolor") || v.eq_ignore_ascii_case("24bit"))
.unwrap_or(false)
}
fn tint_bg(c: Color, scale: f32) -> Color {
let (r, g, b) = rgb_of(c);
Color::Rgb(
(r as f32 * scale) as u8,
(g as f32 * scale) as u8,
(b as f32 * scale) as u8,
)
}
fn fit_labels(plot_w: usize, mut labels: Vec<String>) -> Vec<String> {
let needed = |ls: &[String]| -> usize {
ls.iter().map(|l| l.chars().count()).sum::<usize>() + ls.len().saturating_sub(1)
};
while labels.len() > 2 && needed(&labels) > plot_w {
let keep: Vec<String> = labels
.iter()
.enumerate()
.filter(|(i, _)| *i == 0 || *i == labels.len() - 1 || i % 2 == 0)
.map(|(_, l)| l.clone())
.collect();
if keep.len() == labels.len() {
labels.remove(labels.len() / 2);
} else {
labels = keep;
}
}
if labels.len() == 2 && needed(&labels) > plot_w {
labels.truncate(1);
}
if labels.len() == 1 && needed(&labels) > plot_w {
labels.clear();
}
labels
}
fn chart_gutter(y_labels: &[&str], first_x_label: &str) -> u16 {
let ymax = y_labels
.iter()
.map(|s| s.chars().count())
.max()
.unwrap_or(0) as u16;
let x_overhang = (first_x_label.chars().count() as u16).saturating_sub(1);
ymax.max(x_overhang)
}
struct Plot {
x0: u16,
x1: u16,
top: u16,
bot: u16,
bounds_y: [f64; 2],
}
impl Plot {
fn new(area: Rect, bounds_y: [f64; 2], gutter: u16) -> Option<Self> {
let inner = area.inner(Margin::new(1, 1));
let x0 = inner.x.saturating_add(gutter + 1); let x1 = inner.x + inner.width;
let top = inner.y;
let bot = inner.y + inner.height.saturating_sub(3); (bot > top && x1 > x0).then_some(Self {
x0,
x1,
top,
bot,
bounds_y,
})
}
fn row_of(&self, v: f64) -> u16 {
let ph = (self.bot - self.top) as f64;
let yspan = (self.bounds_y[1] - self.bounds_y[0]).max(0.001);
let r = ((self.bounds_y[1] - v) / yspan * ph).round() as i32;
(self.top as i32 + r).clamp(self.top as i32, self.bot as i32) as u16
}
}
fn tint_in_range_band(
f: &mut Frame,
area: Rect,
bounds_y: [f64; 2],
gutter: u16,
low_y: f64,
high_y: f64,
in_range: Color,
) {
let Some(plot) = Plot::new(area, bounds_y, gutter) else {
return;
};
let (y0, y1) = (plot.row_of(high_y), plot.row_of(low_y));
let band = tint_bg(in_range, 0.20);
let buf = f.buffer_mut();
for yy in y0..=y1 {
for xx in plot.x0..plot.x1 {
if let Some(cell) = buf.cell_mut((xx, yy)) {
cell.set_bg(band);
}
}
}
}
fn interp_xy(pts: &[(f64, f64)], x: f64) -> f64 {
match pts.iter().position(|p| p.0 >= x) {
Some(0) => pts[0].1,
Some(i) => {
let (a, b) = (pts[i - 1], pts[i]);
let span = (b.0 - a.0).max(1.0);
a.1 + (b.1 - a.1) * ((x - a.0) / span)
}
None => pts.last().map(|p| p.1).unwrap_or(0.0),
}
}
fn tint_band(
f: &mut Frame,
area: Rect,
bounds: ([f64; 2], [f64; 2]),
gutter: u16,
low: &[(f64, f64)],
high: &[(f64, f64)],
bg: Color,
) {
let (bounds_x, bounds_y) = bounds;
let Some(plot) = Plot::new(area, bounds_y, gutter) else {
return;
};
if low.len() < 2 || high.len() < 2 {
return;
}
let (xmin, xmax) = (low[0].0, low[low.len() - 1].0);
let xspan = (bounds_x[1] - bounds_x[0]).max(1.0);
let pw = (plot.x1 - plot.x0).max(1) as f64;
let buf = f.buffer_mut();
for xx in plot.x0..plot.x1 {
let x = bounds_x[0] + (xx - plot.x0) as f64 / pw * xspan;
if x < xmin || x > xmax {
continue;
}
let a = plot.row_of(interp_xy(high, x));
let b = plot.row_of(interp_xy(low, x));
for yy in a.min(b)..=a.max(b) {
if let Some(cell) = buf.cell_mut((xx, yy)) {
cell.set_bg(bg);
}
}
}
}
fn tint_agp_fan(
f: &mut Frame,
area: Rect,
bounds_y: [f64; 2],
gutter: u16,
bands: &[agp::Band],
conv: &dyn Fn(f64) -> f64,
base: Color,
) {
let Some(plot) = Plot::new(area, bounds_y, gutter) else {
return;
};
if bands.len() < 2 {
return;
}
let shaded = !truecolor();
let at = |minute: f64, pick: &dyn Fn(&agp::Band) -> f64| -> f64 {
match bands.iter().position(|b| b.minute as f64 >= minute) {
Some(0) => conv(pick(&bands[0])),
Some(i) => {
let (a, b) = (&bands[i - 1], &bands[i]);
let span = (b.minute - a.minute).max(1) as f64;
let t = (minute - a.minute as f64) / span;
conv(pick(a)) + (conv(pick(b)) - conv(pick(a))) * t
}
None => conv(pick(&bands[bands.len() - 1])),
}
};
let pw = (plot.x1 - plot.x0).max(1) as f64;
let buf = f.buffer_mut();
for xx in plot.x0..plot.x1 {
let minute = (xx - plot.x0) as f64 / pw * 1440.0;
let (o_lo, o_hi) = (
plot.row_of(at(minute, &|b| b.p95)),
plot.row_of(at(minute, &|b| b.p05)),
);
let (i_lo, i_hi) = (
plot.row_of(at(minute, &|b| b.p75)),
plot.row_of(at(minute, &|b| b.p25)),
);
for yy in o_lo..=o_hi {
let is_inner = yy >= i_lo && yy <= i_hi;
let Some(cell) = buf.cell_mut((xx, yy)) else {
continue;
};
if shaded {
if cell.symbol() == " " {
cell.set_symbol(if is_inner { "▒" } else { "░" });
cell.set_fg(base);
}
} else {
cell.set_bg(tint_bg(base, if is_inner { 0.34 } else { 0.16 }));
}
}
}
}
fn draw_footer(f: &mut Frame, area: Rect, app: &App) {
if let Some(buf) = &app.date_input {
const PROMPT: &str = " jump to date (YYYY-MM-DD): ";
let line = Line::from(vec![
Span::styled(PROMPT, Style::default().fg(Color::Cyan)),
Span::styled(buf.clone(), Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" · enter confirm · esc cancel"),
]);
f.render_widget(Paragraph::new(line), area);
let x = (PROMPT.chars().count() + buf.chars().count()) as u16;
if x < area.width {
f.set_cursor_position((area.x + x, area.y));
}
return;
}
let mut hints: Option<String> = None;
let warning: Option<(String, Color)> = match app.last_error() {
Some(err) => Some((format!(" error: {err}"), Color::Red)),
None if app.partial().is_some() => Some((
format!(
" ⚠ unavailable, showing last known: {}",
app.partial().unwrap_or_default()
),
Color::Yellow,
)),
None if app.notify_failed => Some((
" ⚠ desktop notifications aren't reaching a notification daemon".into(),
Color::Yellow,
)),
None if !app.demo && app.active_site().is_insecure() => Some((
" ⚠ unencrypted http:// site — the token is sent in clear (settings › site URL)".into(),
Color::Yellow,
)),
None if !app.config_warnings.is_empty() => Some((
format!(" ⚠ config: {}", app.config_warnings.join(" · ")),
Color::Yellow,
)),
None if app.perm_warning => Some((
" ⚠ config.toml is readable by others — run: chmod 600 ~/.config/sugarrush/config.toml"
.into(),
Color::Yellow,
)),
None => {
let alarm = app.alarm_active(chrono::Utc::now().timestamp_millis());
let s = if area.width < 72 {
let mut s = String::from(" q quit · tab view · s settings");
if alarm {
s.push_str(" · a snooze");
}
s.push_str(" · ? help ");
s
} else {
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 alarm {
s.push_str(" · a snooze");
}
s.push_str(" · ? help ");
s
};
hints = Some(s);
None
}
};
let mut spans = Vec::new();
let text = match (warning, hints) {
(Some((msg, color)), _) => {
const HELP: &str = " ? help ";
let width = area.width as usize;
let room = width.saturating_sub(HELP.chars().count());
let shown: String = if msg.chars().count() > room && room > 2 {
msg.chars().take(room - 1).chain(['…']).collect()
} else {
msg
};
let pad = width
.saturating_sub(shown.chars().count())
.saturating_sub(HELP.chars().count());
spans.push(Span::styled(shown, Style::default().fg(color)));
spans.push(Span::raw(" ".repeat(pad)));
Span::styled(HELP, Style::default().fg(Color::Cyan))
}
(None, Some(s)) => Span::raw(s),
(None, None) => Span::raw(""),
};
spans.push(text);
f.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn fmt_clock(ms: i64) -> String {
match Local.timestamp_millis_opt(ms).single() {
Some(dt) => dt.format("%H:%M").to_string(),
None => "--".into(),
}
}
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 {
crate::alert::from_value(sgv, &app.alerts).color(&app.theme)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ages_read_the_way_a_person_would_say_them() {
assert_eq!(fmt_age(0), "0m");
assert_eq!(fmt_age(59_000), "0m");
assert_eq!(fmt_age(90 * 60_000), "1h 30m");
assert_eq!(fmt_age(24 * 3_600_000), "1d 0h");
assert_eq!(fmt_age(50 * 3_600_000), "2d 2h");
assert_eq!(fmt_age(-5_000), "0m");
}
#[test]
fn sparkline_spans_its_range_and_survives_a_flat_series() {
assert_eq!(sparkline_str(&[]), "");
let s = sparkline_str(&[100.0, 150.0, 200.0]);
assert_eq!(s.chars().count(), 3);
assert_eq!(s.chars().next(), Some('▁'));
assert_eq!(s.chars().last(), Some('█'));
let flat = sparkline_str(&[100.0, 100.0, 100.0]);
assert_eq!(flat.chars().count(), 3);
assert!(flat.chars().all(|c| c == '▁'));
}
#[test]
fn the_chart_gutter_fits_the_widest_label_it_must_hold() {
assert_eq!(chart_gutter(&["10.6", "3.3"], "08-08 12:00"), 10);
assert_eq!(chart_gutter(&["10"], "08-08 12:00"), 10);
assert_eq!(chart_gutter(&["120", "60"], "12:00"), 4);
assert_eq!(chart_gutter(&[], ""), 0);
}
#[test]
fn interpolation_holds_flat_outside_the_series() {
let pts = [(0.0, 10.0), (10.0, 20.0)];
assert_eq!(interp_xy(&pts, -5.0), 10.0); assert_eq!(interp_xy(&pts, 0.0), 10.0);
assert_eq!(interp_xy(&pts, 5.0), 15.0); assert_eq!(interp_xy(&pts, 10.0), 20.0);
assert_eq!(interp_xy(&pts, 99.0), 20.0); assert_eq!(interp_xy(&[], 1.0), 0.0);
}
#[test]
fn tinting_moves_toward_the_colour_without_leaving_the_ground() {
let (r, g, b) = rgb_of(tint_bg(Color::Rgb(200, 100, 50), 0.0));
assert!(
r < 30 && g < 30 && b < 30,
"({r},{g},{b}) is not near-black"
);
let full = rgb_of(tint_bg(Color::Rgb(200, 100, 50), 1.0));
assert_eq!(full, (200, 100, 50));
let (r, _, _) = rgb_of(tint_bg(Color::Rgb(200, 100, 50), 0.5));
assert!((80..=140).contains(&r), "midpoint red was {r}");
}
#[test]
fn every_screen_renders_at_every_plausible_size() {
use ratatui::{backend::TestBackend, Terminal};
for (w, h) in [
(200u16, 60u16),
(120, 40),
(80, 24),
(60, 20),
(40, 15),
(20, 10),
(10, 5),
] {
for screen in [Screen::Dashboard, Screen::Settings, Screen::Followers] {
let mut app = demo_app();
app.screen = screen;
let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
term.draw(|f| draw(f, &app))
.unwrap_or_else(|e| panic!("{screen:?} at {w}x{h}: {e}"));
app.show_help = true;
term.draw(|f| draw(f, &app))
.unwrap_or_else(|e| panic!("{screen:?} + help at {w}x{h}: {e}"));
}
}
}
#[test]
fn the_reading_is_on_screen_at_usable_sizes() {
use ratatui::{backend::TestBackend, Terminal};
for (w, h) in [
(200u16, 60u16),
(120, 40),
(80, 30),
(60, 26),
(80, 22),
(63, 20),
(80, 16),
(60, 12),
(40, 8),
] {
let app = demo_app();
let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
term.draw(|f| draw(f, &app)).unwrap();
let text: String = term
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect();
assert!(
text.contains("5.6"),
"the current reading is not in the buffer at {w}x{h}"
);
assert!(
text.contains("in range"),
"the range label is not in the buffer at {w}x{h}"
);
}
}
#[test]
fn the_reading_survives_the_alert_banner_on_a_short_terminal() {
use ratatui::{backend::TestBackend, Terminal};
for (w, h) in [(80u16, 24u16), (63, 20), (80, 16), (60, 12), (40, 8)] {
let mut app = demo_app();
let now = chrono::Utc::now().timestamp_millis();
app.entries = vec![crate::nightscout::Entry {
sgv: 45.0,
date: now,
direction: Some("DoubleDown".into()),
}];
app.evaluate_alert(now);
assert!(app.alert.is_alerting(), "the fixture should be alerting");
let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
term.draw(|f| draw(f, &app)).unwrap();
let text: String = term
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect();
assert!(
text.contains("2.5"),
"the urgent reading is not in the buffer at {w}x{h}"
);
assert!(
text.contains("URGENT LOW"),
"the alert state is not in the buffer at {w}x{h}"
);
}
}
#[test]
fn every_empty_panel_gives_the_same_reason() {
use ratatui::{backend::TestBackend, Terminal};
let mut app = demo_app();
app.entries.clear();
app.minimap_entries.clear();
app.demo = false;
app.mark_offline(
chrono::Utc::now().timestamp_millis(),
"authentication failed".into(),
true,
);
let mut term = Terminal::new(TestBackend::new(120, 40)).unwrap();
term.draw(|f| draw(f, &app)).unwrap();
let text: String = term
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect();
assert!(
!text.contains("loading"),
"a paused fetch must never claim to be loading"
);
let reason = empty_reason(&app);
assert_eq!(
text.matches(reason).count(),
3,
"every empty panel should carry the same reason: {reason:?}"
);
}
#[test]
fn the_dot_follows_the_connection_not_the_view() {
use ratatui::{backend::TestBackend, Terminal};
let render = |app: &App| -> String {
let mut term = Terminal::new(TestBackend::new(120, 40)).unwrap();
term.draw(|f| draw(f, app)).unwrap();
term.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect()
};
let app = demo_app();
assert!(render(&app).contains("● live"), "fresh data reads live");
let mut down = demo_app();
down.demo = false;
down.mark_offline(
chrono::Utc::now().timestamp_millis(),
"can't reach Nightscout".into(),
false,
);
let text = render(&down);
assert!(
!text.contains("● live"),
"the dot must not stay green during an outage"
);
assert!(
text.contains("✖ live"),
"an outage should show a broken dot"
);
}
#[test]
fn text_prompts_position_the_real_cursor_and_never_blink() {
use ratatui::style::Modifier;
use ratatui::{backend::TestBackend, Terminal};
let mut app = demo_app();
app.screen = Screen::Settings;
assert!(app.begin_field_edit(), "the first field should be editable");
for c in "abc".chars() {
app.field_edit_push(c);
}
let mut jumping = demo_app();
jumping.date_input = Some("2026-08".to_string());
for app in [&app, &jumping] {
let mut term = Terminal::new(TestBackend::new(100, 30)).unwrap();
term.draw(|f| draw(f, app)).unwrap();
let pos = term.get_cursor_position().unwrap();
assert!(
pos.x > 0 && pos.y > 0,
"an open text prompt must place the terminal cursor, got {pos:?}"
);
assert!(
!term
.backend()
.buffer()
.content()
.iter()
.any(|c| c.style().add_modifier.contains(Modifier::SLOW_BLINK)),
"nothing may blink"
);
}
}
#[test]
fn help_opens_on_every_screen() {
use ratatui::{backend::TestBackend, Terminal};
for screen in [Screen::Dashboard, Screen::Settings, Screen::Followers] {
let mut app = demo_app();
app.screen = screen;
app.show_help = true;
let mut term = Terminal::new(TestBackend::new(100, 40)).unwrap();
term.draw(|f| draw(f, &app)).unwrap();
let text: String = term
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect();
assert!(
text.contains("press any key to close"),
"the help overlay should be visible on {screen:?}"
);
match screen {
Screen::Settings => assert!(
text.contains("save to config.toml"),
"settings help should document the settings keys"
),
Screen::Followers => {
assert!(text.contains("select a followed person"));
assert!(!text.contains("pan back / forward"));
}
Screen::Dashboard => assert!(
text.contains("pan back / forward"),
"dashboard help should document the graph keys"
),
}
}
}
#[test]
fn every_screen_advertises_help_in_its_footer() {
use ratatui::{backend::TestBackend, Terminal};
for screen in [Screen::Dashboard, Screen::Settings, Screen::Followers] {
let mut app = demo_app();
app.screen = screen;
let mut term = Terminal::new(TestBackend::new(120, 40)).unwrap();
term.draw(|f| draw(f, &app)).unwrap();
let text: String = term
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect();
assert!(
text.contains("? help"),
"{screen:?} does not advertise the help key"
);
}
}
#[test]
fn x_axis_labels_never_collide() {
use ratatui::{backend::TestBackend, Terminal};
for w in [40u16, 50, 60, 70, 80, 100, 140, 200] {
for view in [
crate::app::GraphView::H3,
crate::app::GraphView::H24,
crate::app::GraphView::Agp,
] {
let mut app = demo_app();
app.graph_view = view;
let mut term = Terminal::new(TestBackend::new(w, 40)).unwrap();
term.draw(|f| draw(f, &app)).unwrap();
let b = term.backend().buffer();
for y in 0..40u16 {
let row: String = (0..w).map(|x| b[(x, y)].symbol()).collect();
let stripped: String = row
.chars()
.map(|c| if c.is_ascii_graphic() { c } else { ' ' })
.collect();
for token in stripped.split_whitespace() {
if !token.contains(':') {
continue;
}
let clock = token.len() == 5
&& token.chars().enumerate().all(|(i, c)| {
if i == 2 {
c == ':'
} else {
c.is_ascii_digit()
}
});
assert!(
clock,
"labels collided at width {w} ({view:?}): {token:?} in {row:?}"
);
}
}
}
}
}
#[test]
fn fit_labels_keeps_the_ends() {
let three = || {
vec![
"08-08 23:08".to_string(),
"08-09 00:51".to_string(),
"08-09 02:35".to_string(),
]
};
assert_eq!(fit_labels(40, three()).len(), 3, "all three fit at 40");
let thinned = fit_labels(25, three());
assert_eq!(thinned.len(), 2, "the middle goes first");
assert_eq!(thinned[0], "08-08 23:08");
assert_eq!(thinned[1], "08-09 02:35");
assert_eq!(fit_labels(15, three()).len(), 1);
assert!(fit_labels(4, three()).is_empty());
}
#[test]
fn every_time_in_range_band_has_a_number() {
use ratatui::{backend::TestBackend, Terminal};
let mut app = demo_app();
let now = chrono::Utc::now().timestamp_millis();
app.agp_entries = [40.0, 60.0, 100.0, 200.0, 300.0]
.iter()
.enumerate()
.map(|(i, sgv)| crate::nightscout::Entry {
sgv: *sgv,
date: now - i as i64 * 300_000,
direction: None,
})
.collect();
let mut term = Terminal::new(TestBackend::new(200, 40)).unwrap();
term.draw(|f| draw(f, &app)).unwrap();
let text: String = term
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect();
for expected in ["in range", "below", "very low", "above"] {
assert!(
text.contains(expected),
"{expected:?} has no textual form in the stats panel"
);
}
}
#[test]
fn time_in_range_suffixes_are_dropped_whole() {
use ratatui::{backend::TestBackend, Terminal};
for w in [40u16, 50, 60, 70, 80, 90, 110, 140, 200] {
let mut app = demo_app();
let now = chrono::Utc::now().timestamp_millis();
app.agp_entries = [40.0, 60.0, 100.0, 200.0, 300.0]
.iter()
.enumerate()
.map(|(i, sgv)| crate::nightscout::Entry {
sgv: *sgv,
date: now - i as i64 * 300_000,
direction: None,
})
.collect();
let mut term = Terminal::new(TestBackend::new(w, 40)).unwrap();
term.draw(|f| draw(f, &app)).unwrap();
let b = term.backend().buffer();
for y in 0..40u16 {
let row: String = (0..w).map(|x| b[(x, y)].symbol()).collect();
if !row.contains("TIR") {
continue;
}
for (i, _) in row.match_indices('%') {
let rest = row[i + 1..].trim_start();
assert!(
rest.starts_with("in range")
|| rest.starts_with("below")
|| rest.starts_with("above")
|| rest.starts_with("very low"),
"a percentage was clipped at width {w}: {row:?}"
);
}
}
}
}
#[test]
fn the_agp_fan_survives_without_truecolor() {
use ratatui::{backend::TestBackend, Terminal};
let render = |truecolor: bool| -> (usize, usize) {
if truecolor {
std::env::set_var("COLORTERM", "truecolor");
} else {
std::env::remove_var("COLORTERM");
}
let mut app = demo_app();
app.graph_view = crate::app::GraphView::Agp;
let now = chrono::Utc::now().timestamp_millis();
app.agp_entries = (0..10 * 24 * 4)
.map(|i| crate::nightscout::Entry {
sgv: 100.0 + ((i % 40) as f64 - 20.0) * 3.0,
date: now - i as i64 * 15 * 60_000,
direction: None,
})
.collect();
let mut term = Terminal::new(TestBackend::new(120, 40)).unwrap();
term.draw(|f| draw(f, &app)).unwrap();
let buf = term.backend().buffer();
let shaded = buf
.content()
.iter()
.filter(|c| c.symbol() == "░" || c.symbol() == "▒")
.count();
let tinted = buf
.content()
.iter()
.filter(|c| matches!(c.style().bg, Some(ratatui::style::Color::Rgb(..))))
.count();
(shaded, tinted)
};
let (shaded_16, _) = render(false);
assert!(
shaded_16 > 50,
"without truecolor the fan must be drawn with glyphs, got {shaded_16} cells"
);
let (_, tinted_24) = render(true);
assert!(
tinted_24 > 50,
"with truecolor the fan should still be a background tint, got {tinted_24} cells"
);
std::env::remove_var("COLORTERM");
}
#[test]
fn the_agp_legend_survives_an_insight() {
use ratatui::{backend::TestBackend, Terminal};
let mut app = demo_app();
app.graph_view = crate::app::GraphView::Agp;
let now = chrono::Utc::now().timestamp_millis();
app.agp_entries = (0..10 * 24 * 4)
.map(|i| {
let date = now - i as i64 * 15 * 60_000;
let hour = chrono::Local
.timestamp_millis_opt(date)
.single()
.map(|d| chrono::Timelike::hour(&d))
.unwrap_or(12);
crate::nightscout::Entry {
sgv: if (2..5).contains(&hour) { 55.0 } else { 120.0 },
date,
direction: None,
}
})
.collect();
let mut term = Terminal::new(TestBackend::new(160, 40)).unwrap();
term.draw(|f| draw(f, &app)).unwrap();
let text: String = term
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect();
assert!(text.contains("⚠"), "the fixture should produce an insight");
assert!(
text.contains("median + IQR + 5/95"),
"the legend must survive the headline"
);
assert!(text.contains("low 3.9"), "the low rail is not labelled");
assert!(text.contains("high 10.0"), "the high rail is not labelled");
}
#[test]
fn the_settings_screen_offers_the_alarm_test() {
use ratatui::{backend::TestBackend, Terminal};
let mut app = demo_app();
app.screen = Screen::Settings;
let mut term = Terminal::new(TestBackend::new(100, 60)).unwrap();
term.draw(|f| draw(f, &app)).unwrap();
let text: String = term
.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect();
assert!(
text.contains("Test the alarm"),
"no self-test row in settings"
);
assert!(
text.contains("press enter"),
"the row should say how to run it"
);
}
#[test]
fn settings_show_field_detail_and_scroll_affordance() {
use ratatui::{backend::TestBackend, Terminal};
let mut app = demo_app();
app.screen = Screen::Settings;
let mut term = Terminal::new(TestBackend::new(100, 24)).unwrap();
term.draw(|f| draw(f, &app)).unwrap();
let first: String = term
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(first.contains("Current"));
assert!(first.contains("↓ more"));
assert!(first.contains("name used in follower rows"));
app.settings_sel = Field::ALL.len() - 1;
term.draw(|f| draw(f, &app)).unwrap();
let last: String = term
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(last.contains("↑ more"));
assert!(last.contains("Colorblind palette"));
}
#[test]
fn followers_have_units_severity_rails_and_sparklines() {
use ratatui::{backend::TestBackend, Terminal};
let mut app = demo_app();
app.screen = Screen::Followers;
app.followers = crate::follow::demo(
chrono::Utc::now().timestamp_millis(),
&[app.alerts.clone(), app.alerts.clone(), app.alerts.clone()],
);
let mut term = Terminal::new(TestBackend::new(120, 24)).unwrap();
term.draw(|f| draw(f, &app)).unwrap();
let text: String = term
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(text.contains(app.units.label()));
assert!(text.contains("LAST HOUR"));
assert!(text.contains('▌'));
assert!(text.contains('█') || text.contains('▁'));
}
#[test]
fn follower_overflow_is_scrollable_and_keeps_columns_bounded() {
use ratatui::{backend::TestBackend, Terminal};
let mut app = demo_app();
app.screen = Screen::Followers;
let sample =
crate::follow::demo(chrono::Utc::now().timestamp_millis(), &[app.alerts.clone()])[0]
.clone();
app.followers = (0..20)
.map(|i| {
let mut row = sample.clone();
row.name = format!("{i:02}-person-with-a-very-long-name");
row
})
.collect();
app.follower_scroll = 10;
let mut term = Terminal::new(TestBackend::new(90, 12)).unwrap();
term.draw(|f| draw(f, &app)).unwrap();
let text: String = term
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(text.contains("↑ more"));
assert!(text.contains("↓ more"));
assert!(text.contains('…'), "long names should be ellipsized");
assert!(text.contains("10") || text.contains("11"));
}
fn demo_app() -> App {
let cfg = crate::config::Config::demo();
let alerts = cfg.alerts.resolve(cfg.units);
let sites = cfg.resolve_sites().unwrap();
let mut app = App::new(&cfg, alerts, sites);
app.demo = true;
let now = chrono::Utc::now().timestamp_millis();
app.entries = vec![crate::nightscout::Entry {
sgv: 100.0,
date: now,
direction: Some("Flat".into()),
}];
app.view_start = now - 3 * 3_600_000;
app.view_end = now;
app.evaluate_alert(now);
app
}
}