1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
use ratatui::prelude::*;
use crate::app::App;
use super::{font, gauge, theme};
pub fn render(f: &mut Frame, area: Rect, app: &App) {
let inner = gauge::frame(f, area, "Temp");
let Some(t) = app.medium.as_ref().and_then(|m| m.temp_c) else {
return gauge::unavailable(f, inner);
};
let color = theme::temp_color(t);
// A 3-column vertical thermometer (`▐█▌` per row, a `●` bulb at the
// bottom) used to sit to the left of the compact reading. Dropped: it
// restated the number printed right beside it, it read as a stray progress
// bar rather than a temperature, and stacked `▐█▌` rows seam in
// Terminal.app the same way every other block-glyph column does (see
// `ui/spark.rs`). The chart takes the full card width at both tiers now.
let hero = font::hero_fits(inner);
let head = if hero { gauge::HERO_ROWS } else { 1 };
let rows = Layout::vertical([Constraint::Length(head), Constraint::Min(2)]).split(inner);
if hero {
gauge::hero(f, rows[0], &format!("{t:.1}°C"), &format!("{t:.0}°C"), color);
} else {
gauge::readout(f, rows[0], &format!("{t:.1}°C"), color);
}
render_chart(f, rows[1], app, color);
}
fn render_chart(f: &mut Frame, area: Rect, app: &App, color: Color) {
// Baseline-shift 30→105 °C onto 0→75 so the idle-to-hot band uses the full
// bar height instead of hugging the top.
let data: Vec<u64> = app
.temp_hist
.iter()
.map(|v| (v - 30.0).clamp(0.0, 75.0).round() as u64)
.collect();
super::spark::render(f, area, &data, 75, Style::default().fg(color));
}
#[cfg(test)]
mod tests {
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
use ratatui::style::Color;
use ratatui::Terminal;
use crate::app::App;
fn draw(w: u16, h: u16) -> String {
let mut t = Terminal::new(TestBackend::new(w, h)).unwrap();
let app = App::demo();
t.draw(|f| super::render(f, f.area(), &app)).unwrap();
t.backend().buffer().content().iter().map(|c| c.symbol()).collect()
}
fn draw_buffer(w: u16, h: u16, app: &App) -> Buffer {
let mut t = Terminal::new(TestBackend::new(w, h)).unwrap();
t.draw(|f| super::render(f, f.area(), app)).unwrap();
t.backend().buffer().clone()
}
/// Reconstruct row `y` of the buffer as a `#`/space bitmap string by
/// sampling each cell's background colour — the only place a filled hero
/// pixel shows up now that hero digits are background fills rather than
/// foreground glyph characters (see `ui/font.rs`'s doc comment).
fn bg_bitmap_row(buf: &Buffer, y: u16, width: u16, color: Color) -> String {
(0..width).map(|x| if buf[(x, y)].style().bg == Some(color) { '#' } else { ' ' }).collect()
}
#[test]
fn neither_tier_draws_a_thermometer() {
// demo temp = 88.0 → "88.0°C". The hero digits are background-filled
// cells now, not foreground quadrant glyphs, so prove the "8" reads
// correctly by sampling bg colours off the buffer and matching them
// against the same bitmap `hero_lines` was built from, rather than
// grepping the rendered text for glyph characters.
let app = App::demo();
let color = super::theme::temp_color(88.0);
let buf = draw_buffer(40, 12, &app);
// The card's border occupies row 0, so the hero rows start at y=1,
// not y=0 — scan every row and require each expected bitmap row to
// appear (as a substring, since it's left-padded by the border/x
// offset) somewhere in the buffer, rather than hard-coding its exact
// row/column position.
let rows: Vec<String> = (0..buf.area.height).map(|y| bg_bitmap_row(&buf, y, 40, color)).collect();
let expected = super::font::big_text("88.0°C");
for want in &expected {
assert!(
rows.iter().any(|row| row.contains(want.as_str())),
"no buffer row has bitmap {want:?} for \"88.0°C\""
);
}
// The vertical `▐█▌` thermometer is gone from both tiers now, not
// just the hero one — it restated the reading printed beside it and
// seamed in Terminal.app. The compact tier keeps the numeric readout
// and gives the rest of the card to the chart.
let full = draw(40, 12);
assert!(!full.contains("▐"), "thermometer should be gone in hero tier");
let compact = draw(40, 10);
assert!(!compact.contains("▐"), "thermometer should be gone in compact tier too");
assert!(!compact.contains("●"), "thermometer bulb should be gone as well");
assert!(compact.contains("88.0°C"), "compact tier should still print the reading");
}
#[test]
fn fill_ratio_clamps() {
let ratio = |t: f32| ((t - 30.0) / 75.0).clamp(0.0, 1.0);
assert_eq!(ratio(20.0), 0.0);
assert_eq!(ratio(105.0), 1.0);
assert!((ratio(67.5) - 0.5).abs() < 0.01);
}
#[test]
fn hero_falls_back_to_coarse_when_precise_would_overflow() {
// 30x12 -> inner 28x10, full hero tier engaged (width >= 28, height >= 9).
// The quadrant font is narrower than the old full-block face (3-col
// digits instead of 4), so a realistic 3-integer-digit reading like
// "100.5°C" (26 glyph-cols) now fits the 28-wide card and no longer
// exercises the fallback — at this card-width floor, no realistic
// sensor temperature (up to ~150°C) overflows any more. The guard
// still has to hold for garbage/corrupted sensor input, so drive it
// with a value no real Mac would report: "1000.4°C" formatted to 1
// decimal is 30 glyph-columns wide, wider than the 28-wide inner
// area, so it must fall back to "1000°C" (0 decimals) instead of
// truncating mid-glyph. The precise-width row never fits inside 28
// cols, so its top row is not a substring of the buffer; the coarse
// row (23 cols) does fit and must appear intact.
let mut app = App::demo();
app.medium.as_mut().unwrap().temp_c = Some(1000.4);
let color = super::theme::temp_color(1000.4);
let buf = draw_buffer(30, 12, &app);
// Sample the same 4 hero rows as bg-colour bitmaps rather than
// grepping rendered text: filled hero pixels carry `color` as their
// background now, and every cell's symbol is a plain space.
let rows: Vec<String> = (0..4).map(|y| bg_bitmap_row(&buf, y, 30, color)).collect();
let precise_row0 = super::font::big_text("1000.4°C").remove(0);
let coarse_row0 = super::font::big_text("1000°C").remove(0);
assert!(
!rows.iter().any(|r| r.contains(precise_row0.as_str())),
"full-precision hero row rendered intact — should have overflowed the 28-wide card"
);
assert!(
rows.iter().any(|r| r.contains(coarse_row0.as_str())),
"coarse fallback row missing — hero should fall back to \"1000°C\" when precise overflows"
);
}
#[test]
fn history_renders_block_sparkline() {
// Drive the chart helper directly with an area exactly as wide as
// the pushed history, so the whole buffer IS the chart: every column
// is pinned to a known sample, not just "a bar appears somewhere".
let mut t = Terminal::new(TestBackend::new(5, 1)).unwrap();
let mut app = App::new(false);
for v in [35.0_f32, 50.0, 70.0, 85.0, 100.0] {
app.temp_hist.push(v);
}
t.draw(|f| super::render_chart(f, f.area(), &app, super::theme::TEXT)).unwrap();
let s: String = t.backend().buffer().content().iter().map(|c| c.symbol()).collect();
// baseline-shift (v-30).clamp(0,75), then level = shifted*8/75:
// 35→5→0(' '), 50→20→2(▂), 70→40→4(▄), 85→55→5(▅), 100→70(clamped 75 cap)→7(▇)
assert_eq!(s, " ▂▄▅▇", "chart mis-scaled or mis-positioned");
assert_eq!(
s.chars().last().unwrap(),
'▇',
"newest sample (100°C, the tallest) must land at the right edge"
);
}
}