mirador 1.22.0

An opinionated personal dashboard for your terminal: world clocks, a calendar, weather, tasks, notes, a market watchlist, and live CPU and network graphs.
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
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
//! Temperatures: the hottest thing in the machine, its history, and the rest
//! of the sensors under it.
//!
//! Read through `sysinfo::Components`, which is already in the tree for the
//! cpu and memory panels. What it hands back is the platform's own list — on
//! Apple silicon that is fourteen `PMU tdie` readings for one die, a
//! calibration reference that is not a temperature at all, and eight `tdev`
//! entries reporting minus nine thousand degrees — so the panel's real work is
//! [`group`]: turning the platform's list into the one a person would write.
//!
//! The face is the cpu panel's: a readout, a braille history, and a table
//! where the per-core strip would be. Same ramp, because a temperature in
//! degrees Celsius and a load in percent both run 0–100 and both mean "how
//! hard is this machine working" — the panels change colour together.

use std::collections::VecDeque;
use std::time::{Duration, Instant};

use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyModifiers};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::Span;
use ratatui::widgets::Paragraph;

use crate::chart::BrailleGraph;
use crate::config::TemperatureConfig;
use crate::frame::Binding;
use crate::grid::{Column, Grid};
use crate::keymap::{KeysConfig, Meta, PanelKeymap};
use crate::panel::{Panel, RenderContext};

/// The table under the graph: every sensor group, hottest first.
pub const COLUMNS: &[Column] = &[
    Column::flex("sensor", 1),
    Column::fixed("now", 5).right(),
    Column::fixed("peak", 5).right().drops_below(26),
];

/// Readings outside this range are a sensor that is not connected or a
/// platform reporting a placeholder, not a temperature. Apple's `PMU tdev`
/// entries sit at −9201; a value like that in the table would be the panel
/// reporting a thermometer that does not exist.
const PLAUSIBLE_C: std::ops::RangeInclusive<f32> = -40.0..=150.0;

/// Platform labels renamed to what they are. Each entry is a prefix matched
/// case-insensitively against the label with its trailing digits stripped, so
/// `PMU tdie1` through `PMU tdie14` all become one row called `CPU die`.
/// Anything not here keeps its own name, minus the trailing digits — honest,
/// if less pretty, and the rows still group.
const RENAMES: &[(&str, &str)] = &[
    // Apple silicon, via IOKit. `tdie` is the die; `tcal` is a calibration
    // reference and is dropped below rather than renamed.
    ("PMU tdie", "CPU die"),
    ("NAND CH", "SSD"),
    ("gas gauge battery", "Battery"),
    // Linux hwmon, as `sysinfo` labels it: driver name, then the sensor.
    ("coretemp Package id", "CPU package"),
    ("coretemp Core", "CPU core"),
    ("k10temp Tctl", "CPU (Tctl)"),
    ("k10temp Tccd", "CPU die"),
    ("nvme Composite", "SSD"),
    ("amdgpu edge", "GPU"),
    ("acpitz", "Motherboard"),
];

/// Labels that are not temperatures of anything, whatever they read.
const DROPPED: &[&str] = &["PMU tcal"];

/// One row of the table: a group of sensors under one name.
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Sensor {
    pub name: String,
    /// Degrees Celsius, the hottest in the group.
    pub now: f32,
    /// The hottest the group has reported since the panel started.
    pub peak: f32,
}

/// The temperature panel.
pub struct TemperaturePanel {
    config: TemperatureConfig,
    /// `[temperature.keys]` over the defaults.
    keys: PanelKeymap<TemperatureAction>,
    components: Option<sysinfo::Components>,
    /// Hottest first.
    sensors: Vec<Sensor>,
    /// The hottest reading at each sample, whole degrees Celsius, oldest first.
    history: VecDeque<u64>,
    /// Cells the graph was last drawn into, so the history can grow to fill it.
    graph_cells: usize,
    last_sample: Option<Instant>,
    /// `u` toggles this; the config seeds it.
    celsius: bool,
}

// `sysinfo::Components` has no `Debug`.
impl std::fmt::Debug for TemperaturePanel {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("TemperaturePanel")
            .field("sensors", &self.sensors)
            .field("samples", &self.history.len())
            .finish_non_exhaustive()
    }
}

impl TemperaturePanel {
    /// Build the panel and take a first reading.
    pub fn new(config: TemperatureConfig) -> Self {
        let mut panel = Self {
            celsius: config.units != "fahrenheit",
            history: VecDeque::with_capacity(config.history.max(1)),
            keys: PanelKeymap::or_defaults("temperature", ACTIONS, &config.keys),
            config,
            components: Some(sysinfo::Components::new_with_refreshed_list()),
            sensors: Vec::new(),
            graph_cells: 0,
            last_sample: None,
        };
        panel.sample();
        panel
    }

    /// A panel holding `sensors` and `history`, reading nothing, for tests in
    /// other modules and for the sweep.
    #[cfg(test)]
    pub(crate) fn with_sensors(
        config: TemperatureConfig,
        sensors: Vec<Sensor>,
        history: &[u64],
    ) -> Self {
        Self {
            celsius: config.units != "fahrenheit",
            history: history.iter().copied().collect(),
            keys: PanelKeymap::or_defaults("temperature", ACTIONS, &config.keys),
            config,
            components: None,
            sensors,
            graph_cells: 0,
            last_sample: Some(Instant::now()),
        }
    }

    /// An Apple silicon laptop at work, for the sweep and the dump.
    #[cfg(test)]
    pub(crate) fn canned(config: TemperatureConfig) -> Self {
        let sensor = |name: &str, now: f32, peak: f32| Sensor {
            name: name.into(),
            now,
            peak,
        };
        Self::with_sensors(
            config,
            vec![
                sensor("CPU die", 47.3, 61.0),
                sensor("SSD", 34.0, 36.0),
                sensor("Battery", 29.0, 31.0),
            ],
            &[41, 43, 47, 52, 49, 47],
        )
    }

    /// Read every sensor if enough time has passed since the last reading.
    /// Returns whether anything was read.
    fn sample(&mut self) -> bool {
        let interval = Duration::from_secs(self.config.sample_secs.max(1));
        if self.last_sample.is_some_and(|at| at.elapsed() < interval) {
            return false;
        }
        self.last_sample = Some(Instant::now());
        let Some(components) = self.components.as_mut() else {
            return false;
        };
        components.refresh(true);
        let raw = components
            .iter()
            .map(|c| (c.label().to_string(), c.temperature(), c.max()))
            .collect::<Vec<_>>();
        self.sensors = group(raw, &self.sensors);
        if let Some(hottest) = self.sensors.first() {
            let capacity = crate::samples::capacity(self.config.history, self.graph_cells);
            crate::samples::push_bounded(
                &mut self.history,
                hottest.now.clamp(0.0, 150.0).round() as u64,
                capacity,
            );
        }
        true
    }

    /// A reading in the display units.
    fn shown(&self, celsius: f32) -> f32 {
        if self.celsius {
            celsius
        } else {
            celsius * 9.0 / 5.0 + 32.0
        }
    }

    fn unit(&self) -> &'static str {
        if self.celsius { "°C" } else { "°F" }
    }

    /// Why there is nothing, on this platform. Said rather than guessed at:
    /// the first version told every Windows user to run as administrator,
    /// which is the answer on some machines and wrong on many — `sysinfo`
    /// reads one ACPI thermal zone through WMI, and firmware that exposes
    /// none gives nothing at any privilege. On NetBSD `sysinfo` reads a
    /// FreeBSD sysctl that does not exist there (its sensors are in envsys);
    /// #255 is the report.
    const fn empty_hint() -> &'static str {
        if cfg!(windows) {
            "Windows exposes at most one ACPI thermal zone, which many machines \
             do not provide and some provide only to an administrator"
        } else if cfg!(target_os = "netbsd") {
            "sysinfo does not read NetBSD's envsys sensors yet"
        } else {
            "This machine reports none"
        }
    }

    /// Say nothing, centred, rather than draw an empty graph over an empty
    /// table. The hint is prose, so it wraps to the width rather than being
    /// cut — the platform's reason is the useful half of the message.
    fn draw_empty(frame: &mut Frame, area: Rect, theme: &crate::theme::Theme) {
        Self::draw_empty_with(frame, area, theme, Self::empty_hint());
    }

    /// `draw_empty` with the hint as a parameter, so a test on any platform
    /// can hand it the long Windows sentence and watch it wrap.
    fn draw_empty_with(frame: &mut Frame, area: Rect, theme: &crate::theme::Theme, hint: &str) {
        let mut lines = vec!["No temperature sensors".to_string()];
        lines.extend(crate::grid::wrap(hint, usize::from(area.width)));
        crate::chart::draw_notice(frame, area, theme, &lines);
    }
}

/// The platform's sensor list as a person would write it.
///
/// Each raw entry is a label, a reading and the platform's own running
/// maximum. Readings that are not plausible temperatures are dropped, so are
/// labels that are not temperatures of anything, trailing digits come off
/// the label so `Core 0` and `Core 7` share a row, known labels are renamed,
/// and each name keeps the hottest reading in its group. The peak is the
/// highest the group has shown since the panel started — `previous` carries
/// it across samples, because the platform's own maximum is not always kept.
pub(crate) fn group(
    raw: Vec<(String, Option<f32>, Option<f32>)>,
    previous: &[Sensor],
) -> Vec<Sensor> {
    let mut groups: Vec<Sensor> = Vec::new();
    for (label, now, max) in raw {
        let Some(now) = now.filter(|t| PLAUSIBLE_C.contains(t)) else {
            continue;
        };
        let Some(name) = display_name(&label) else {
            continue;
        };
        let peak = max
            .filter(|t| PLAUSIBLE_C.contains(t))
            .unwrap_or(now)
            .max(now);
        match groups.iter_mut().find(|g| g.name == name) {
            Some(g) => {
                g.now = g.now.max(now);
                g.peak = g.peak.max(peak);
            }
            None => groups.push(Sensor { name, now, peak }),
        }
    }
    for g in &mut groups {
        if let Some(p) = previous.iter().find(|p| p.name == g.name) {
            g.peak = g.peak.max(p.peak);
        }
    }
    groups.sort_by(|a, b| {
        b.now
            .partial_cmp(&a.now)
            .unwrap_or(std::cmp::Ordering::Equal)
            .then_with(|| a.name.cmp(&b.name))
    });
    groups
}

/// The row name for a platform label, or `None` for a label that is not a
/// temperature of anything.
fn display_name(label: &str) -> Option<String> {
    const SEPARATORS: [char; 3] = [' ', '_', '-'];
    let trimmed = label.trim();
    if trimmed.is_empty() {
        return None;
    }
    let lower = trimmed.to_lowercase();
    if DROPPED.iter().any(|d| lower.starts_with(&d.to_lowercase())) {
        return None;
    }
    // Peel the label back to the thing it names. The sensor layer comes off
    // once: its number, and a `temp` standing as a word of its own, which says
    // nothing a temperature panel has not already said. After that a number
    // comes off only where a separator introduces it — the `_N` the kernel
    // puts on some hwmon device names, as in `iwlwifi_1 temp1` — because
    // digits glued to a word are part of its name: repeating the first peel
    // took `k10temp temp1` down to `k`.
    let digits = |c: char| c.is_ascii_digit();
    let mut stem = trimmed
        .trim_end_matches(digits)
        .trim_end_matches(SEPARATORS);
    let before_temp = strip_suffix_ci(stem, "temp");
    if before_temp.len() < stem.len()
        && (before_temp.is_empty() || before_temp.ends_with(SEPARATORS))
    {
        stem = before_temp.trim_end_matches(SEPARATORS);
    }
    loop {
        let bare = stem.trim_end_matches(digits);
        if bare.len() == stem.len() || !bare.ends_with(SEPARATORS) {
            break;
        }
        stem = bare.trim_end_matches(SEPARATORS);
    }
    if stem.is_empty() {
        return Some(trimmed.to_string());
    }
    let stem_lower = stem.to_lowercase();
    for (prefix, name) in RENAMES {
        if stem_lower.starts_with(&prefix.to_lowercase()) {
            return Some((*name).to_string());
        }
    }
    Some(stem.to_string())
}

/// `text` without a trailing `suffix`, compared without case; unchanged when
/// it does not end with one.
fn strip_suffix_ci<'a>(text: &'a str, suffix: &str) -> &'a str {
    if text.len() >= suffix.len() && text.is_char_boundary(text.len() - suffix.len()) {
        let (head, tail) = text.split_at(text.len() - suffix.len());
        if tail.eq_ignore_ascii_case(suffix) {
            return head;
        }
    }
    text
}

/// What this panel's keys do.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TemperatureAction {
    Units,
}

/// This panel's keys, under `[temperature.keys]`.
pub const ACTIONS: &[Meta<TemperatureAction>] = &[Meta {
    action: TemperatureAction::Units,
    name: "units",
    defaults: &[(KeyCode::Char('u'), KeyModifiers::NONE)],
    label: "units",
    primary: true,
    joins: false,
    about: "switch between Celsius and Fahrenheit",
}];

/// `[temperature.keys]` laid over [`ACTIONS`], or why it cannot be.
pub fn keymap(keys: &KeysConfig) -> Result<PanelKeymap<TemperatureAction>, String> {
    PanelKeymap::new("temperature", ACTIONS, keys)
}

impl Panel for TemperaturePanel {
    fn title(&self) -> String {
        "Temperature".to_string()
    }

    fn counter(&self) -> Option<String> {
        match self.sensors.len() {
            0 => None,
            1 => Some("1 sensor".to_string()),
            n => Some(format!("{n} sensors")),
        }
    }

    fn bindings(&self) -> &[Binding] {
        self.keys.bindings()
    }

    fn set_keys(&mut self, config: &crate::config::Config) {
        self.keys = PanelKeymap::or_defaults("temperature", ACTIONS, &config.temperature.keys);
    }

    fn refresh_interval(&self) -> Duration {
        Duration::from_secs(1)
    }

    fn tick(&mut self) -> bool {
        self.sample()
    }

    fn handle_key(&mut self, key: ratatui::crossterm::event::KeyEvent) -> crate::panel::KeyOutcome {
        if self.keys.action(key) == Some(TemperatureAction::Units) {
            self.celsius = !self.celsius;
            return crate::panel::KeyOutcome::Consumed;
        }
        crate::panel::KeyOutcome::Ignored
    }

    fn remember(&self, state: &mut crate::state::UiState) {
        state.temperature_units = Some(
            if self.celsius {
                "celsius"
            } else {
                "fahrenheit"
            }
            .into(),
        );
    }

    fn alert(&self) -> Option<crate::panel::Alert> {
        // A die past its limit is the one reading here that gets worse if
        // nobody acts — a throttled machine is the mild outcome, a shutdown
        // the other. Zero in the config switches it off.
        let limit = self.config.alert_above_c;
        let hottest = self.sensors.first()?;
        (limit > 0 && hottest.now > f32::from(limit)).then(|| {
            crate::panel::Alert::soon(format!(
                "{} at {:.0}{}",
                hottest.name,
                self.shown(hottest.now),
                self.unit()
            ))
        })
    }

    fn render(&mut self, frame: &mut Frame, area: Rect, ctx: RenderContext<'_>) {
        let theme = ctx.theme;
        if area.height == 0 || area.width == 0 {
            return;
        }
        let Some(hottest) = self.sensors.first().cloned() else {
            Self::draw_empty(frame, area, theme);
            return;
        };

        let gradient = &ctx.gradients.cpu;
        let track = Style::default().fg(theme.track);
        let muted = Style::default().fg(theme.muted);

        // The table takes at most half the panel and only when it can show a
        // header and a row; the graph keeps the rest.
        let wanted = u16::try_from(self.sensors.len() + 1).unwrap_or(u16::MAX);
        let table_rows = if area.height >= 5 {
            wanted.min(area.height / 2)
        } else {
            0
        };
        let table_rows = if table_rows >= 2 { table_rows } else { 0 };

        let rows = Layout::vertical([
            Constraint::Length(1),
            Constraint::Min(1),
            Constraint::Length(table_rows),
        ])
        .split(area);

        // The readout: the hottest thing, in the ramp's colour at its Celsius
        // value, then what it is, then how much history the graph holds. The
        // figure and its unit are one part; the name and the age drop whole.
        let colour = gradient.at(hottest.now.clamp(0.0, 100.0).round() as i64);
        frame.render_widget(
            Paragraph::new(crate::grid::assemble(
                vec![
                    vec![
                        Span::styled(
                            format!("{:.1}", self.shown(hottest.now)),
                            Style::default().fg(colour).add_modifier(Modifier::BOLD),
                        ),
                        Span::styled(self.unit(), muted),
                    ],
                    vec![Span::styled(
                        format!(" {}", crate::glyphs::utility(&hottest.name)),
                        Style::default()
                            .fg(theme.label)
                            .add_modifier(Modifier::BOLD),
                    )],
                    vec![Span::styled(
                        format!(
                            "   {}s",
                            self.history.len() as u64 * self.config.sample_secs.max(1)
                        ),
                        muted,
                    )],
                ],
                rows[0].width,
            )),
            rows[0],
        );

        self.graph_cells = rows[1].width as usize;
        if rows[1].height > 0 {
            BrailleGraph::of_history(&self.history, 100, gradient)
                .track_style(track)
                .render(rows[1], frame.buffer_mut());
        }

        if table_rows >= 2 {
            let grid = Grid::new(COLUMNS, rows[2].width);
            let mut y = rows[2].y;
            frame.render_widget(
                Paragraph::new(grid.header(theme)),
                Rect::new(rows[2].x, y, rows[2].width, 1),
            );
            y += 1;
            for sensor in self.sensors.iter().take(usize::from(table_rows) - 1) {
                let tone = gradient.at(sensor.now.clamp(0.0, 100.0).round() as i64);
                let line = grid.row(&[
                    Span::styled(sensor.name.clone(), Style::default().fg(theme.text)),
                    Span::styled(
                        format!("{:.0}°", self.shown(sensor.now)),
                        Style::default().fg(tone),
                    ),
                    Span::styled(format!("{:.0}°", self.shown(sensor.peak)), muted),
                ]);
                frame.render_widget(
                    Paragraph::new(line),
                    Rect::new(rows[2].x, y, rows[2].width, 1),
                );
                y += 1;
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::widgets::testing::screen;
    use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};

    fn apple() -> Vec<(String, Option<f32>, Option<f32>)> {
        let mut raw = vec![
            ("PMU tcal".to_string(), Some(51.8), Some(51.8)),
            ("NAND CH0 temp".to_string(), Some(32.0), Some(32.0)),
            ("gas gauge battery".to_string(), Some(29.0), Some(29.0)),
        ];
        for i in 1..=14 {
            raw.push((
                format!("PMU tdie{i}"),
                Some(37.0 + i as f32 * 0.2),
                Some(40.0),
            ));
        }
        for i in 1..=8 {
            raw.push((format!("PMU tdev{i}"), Some(-9201.1), Some(0.0)));
        }
        raw
    }

    fn sensors() -> Vec<Sensor> {
        group(apple(), &[])
    }

    fn close(a: f32, b: f32) -> bool {
        (a - b).abs() < 0.01
    }

    fn panel(sensors: Vec<Sensor>) -> TemperaturePanel {
        TemperaturePanel::with_sensors(TemperatureConfig::default(), sensors, &[38, 39, 40, 41, 40])
    }

    /// Twenty-five platform entries become three rows a person would write:
    /// fourteen die sensors as one, the calibration reference gone, the
    /// eight impossible readings gone.
    #[test]
    fn apple_silicons_sensor_list_becomes_three_rows() {
        let rows = sensors();
        let names: Vec<&str> = rows.iter().map(|s| s.name.as_str()).collect();
        assert_eq!(names, ["CPU die", "SSD", "Battery"], "{rows:?}");
        let die = &rows[0];
        assert!(
            (die.now - 39.8).abs() < 0.01,
            "the hottest of the fourteen: {die:?}"
        );
        assert!(
            (die.peak - 40.0).abs() < 0.01,
            "and the platform's own maximum: {die:?}"
        );
        assert!(
            rows.iter().all(|s| PLAUSIBLE_C.contains(&s.now)),
            "{rows:?}"
        );
    }

    #[test]
    fn linux_hwmon_labels_group_and_rename() {
        let raw = vec![
            ("coretemp Package id 0".to_string(), Some(52.0), Some(70.0)),
            ("coretemp Core 0".to_string(), Some(48.0), Some(66.0)),
            ("coretemp Core 1".to_string(), Some(51.0), Some(69.0)),
            ("nvme Composite".to_string(), Some(41.0), None),
            ("acpitz temp1".to_string(), Some(27.8), None),
            ("iwlwifi_1 temp1".to_string(), Some(45.0), None),
        ];
        let rows = group(raw, &[]);
        let names: Vec<&str> = rows.iter().map(|s| s.name.as_str()).collect();
        assert_eq!(
            names,
            ["CPU package", "CPU core", "iwlwifi", "SSD", "Motherboard"],
            "hottest first, unknown labels kept minus their digits: {rows:?}"
        );
        assert!(close(rows[1].now, 51.0), "the hotter core");
        assert!(close(rows[1].peak, 69.0));
        assert!(
            close(rows[3].peak, 41.0),
            "no platform maximum: the peak is the reading"
        );
    }

    /// A driver whose name ends in digits keeps them. `sysinfo` names an
    /// unlabelled hwmon sensor `{driver} temp{n}`, and peeling digits until
    /// nothing changed took `k10temp temp1` down through `k10temp` and `k10`
    /// to `k` — a row, a readout and an alert naming a chip nobody could
    /// recognise. A second number comes off only where a separator introduces
    /// it, as in the `_1` of the kernel's `iwlwifi_1`.
    #[test]
    fn a_driver_name_ending_in_digits_keeps_them() {
        for (label, expected) in [
            ("k10temp temp1", "k10temp"),
            ("lm75 temp1", "lm75"),
            ("jc42 temp2", "jc42"),
            ("it8728 temp1", "it8728"),
            ("nct6775 temp3", "nct6775"),
            ("lm75_1 temp1", "lm75"),
            ("iwlwifi_1 temp1", "iwlwifi"),
            ("acpitz temp1", "Motherboard"),
            ("NAND CH0 temp", "SSD"),
            ("PMU tdie14", "CPU die"),
            ("k10temp Tccd1", "CPU die"),
            ("coretemp Package id 0", "CPU package"),
            ("temp1", "temp1"),
        ] {
            assert_eq!(display_name(label).as_deref(), Some(expected), "{label}");
        }
    }

    /// The peak is the panel's memory, not the platform's: a group that
    /// cooled since the last sample keeps the higher figure.
    #[test]
    fn the_peak_survives_a_sample_that_cooled() {
        let hot = group(vec![("PMU tdie1".to_string(), Some(80.0), None)], &[]);
        let cooler = group(vec![("PMU tdie1".to_string(), Some(50.0), None)], &hot);
        assert!(close(cooler[0].now, 50.0));
        assert!(close(cooler[0].peak, 80.0));
    }

    #[test]
    fn u_switches_units_and_is_remembered() {
        let mut p = panel(sensors());
        assert_eq!(p.unit(), "°C");
        let out = p.handle_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE));
        assert_eq!(out, crate::panel::KeyOutcome::Consumed);
        assert_eq!(p.unit(), "°F");
        assert!((p.shown(100.0) - 212.0).abs() < 0.01);
        let mut state = crate::state::UiState::default();
        p.remember(&mut state);
        assert_eq!(state.temperature_units.as_deref(), Some("fahrenheit"));
        assert!(
            p.bindings().iter().any(|b| b.key == "u"),
            "the key is documented in the frame"
        );
    }

    #[test]
    fn the_alert_names_the_hottest_thing_past_the_limit_and_can_be_switched_off() {
        let hot = vec![Sensor {
            name: "CPU die".into(),
            now: 97.4,
            peak: 98.0,
        }];
        assert_eq!(
            panel(hot.clone()).alert().map(|a| a.text),
            Some("CPU die at 97°C".to_string())
        );
        assert!(
            panel(sensors()).alert().is_none(),
            "forty degrees is not news"
        );
        let off = TemperatureConfig {
            alert_above_c: 0,
            ..TemperatureConfig::default()
        };
        assert!(
            TemperaturePanel::with_sensors(off, hot, &[])
                .alert()
                .is_none()
        );
        assert!(panel(Vec::new()).alert().is_none());
    }

    #[test]
    fn the_border_counts_sensor_groups_not_platform_entries() {
        assert_eq!(panel(sensors()).counter().as_deref(), Some("3 sensors"));
        assert_eq!(
            panel(vec![Sensor {
                name: "SSD".into(),
                now: 40.0,
                peak: 40.0
            }])
            .counter()
            .as_deref(),
            Some("1 sensor")
        );
        assert_eq!(panel(Vec::new()).counter(), None);
    }

    /// Readout, graph, then the table with a header and every group under
    /// it — and no cell in the table left blank.
    #[test]
    fn the_face_reads_readout_graph_table() {
        let mut p = panel(sensors());
        let rows = screen(&mut p, 40, 10);
        assert!(rows[0].starts_with("39.8°C CPU DIE"), "{rows:?}");
        assert!(
            rows[0].ends_with("25s"),
            "five samples at five seconds: {rows:?}"
        );
        assert!(
            rows.iter()
                .any(|r| r.contains('⣀') || r.contains('⣿') || r.contains('⡀')),
            "a graph: {rows:?}"
        );
        let header = rows
            .iter()
            .position(|r| r.starts_with("SENSOR"))
            .expect("a table header");
        assert!(
            rows[header].contains("NOW") && rows[header].ends_with("PEAK"),
            "{rows:?}"
        );
        assert!(
            rows[header + 1].starts_with("CPU die") && rows[header + 1].ends_with("40°"),
            "{rows:?}"
        );
        assert!(rows[header + 2].starts_with("SSD"), "{rows:?}");
        assert!(rows[header + 3].starts_with("Battery"), "{rows:?}");
        for row in &rows[header + 1..header + 4] {
            assert!(
                row.matches('°').count() == 2,
                "now and peak both filled: {row:?}"
            );
        }
    }

    #[test]
    fn a_short_panel_keeps_the_graph_and_drops_the_table() {
        let mut p = panel(sensors());
        let rows = screen(&mut p, 40, 4);
        assert!(rows.iter().all(|r| !r.starts_with("SENSOR")), "{rows:?}");
        assert!(rows[0].starts_with("39.8°C"), "{rows:?}");
    }

    #[test]
    fn a_machine_with_no_sensors_says_so_and_says_why() {
        let mut p = panel(Vec::new());
        let rows = screen(&mut p, 40, 6);
        let text = rows.join("\n");
        assert!(text.contains("No temperature sensors"), "{rows:?}");
        assert!(!text.contains('⣀'), "no empty graph over nothing: {rows:?}");
        assert!(
            !text.contains("administrator") || cfg!(windows),
            "the administrator line is Windows' alone: {rows:?}"
        );
    }

    /// The headline over the reason is ellipsised when the panel is
    /// narrower than it, as the disk and battery panels' empty states always
    /// were. This one alone handed `No temperature sensors` over whole, and
    /// what fitted was kept — `No temperatu` at twelve columns, with nothing
    /// to say that the rest of a word had gone.
    #[test]
    fn the_headline_is_marked_when_the_panel_is_too_narrow_for_it() {
        use ratatui::Terminal;
        use ratatui::backend::TestBackend;
        let config = crate::config::Config::default();
        let mut cut = 0;
        for width in 1..=30u16 {
            let mut terminal = Terminal::new(TestBackend::new(width, 6)).unwrap();
            terminal
                .draw(|frame| {
                    TemperaturePanel::draw_empty_with(frame, frame.area(), &config.theme, "");
                })
                .unwrap();
            let rows = crate::widgets::testing::rows(terminal.backend().buffer());
            let headline = rows
                .iter()
                .map(|row| row.trim())
                .find(|row| !row.is_empty())
                .unwrap_or_default();
            if usize::from(width) >= "No temperature sensors".len() {
                assert_eq!(headline, "No temperature sensors", "at {width}: {rows:?}");
            } else {
                assert!(
                    headline.ends_with('…')
                        && "No temperature sensors".starts_with(headline.trim_end_matches('…')),
                    "at {width} the headline was {headline:?}"
                );
                cut += 1;
            }
        }
        assert_eq!(cut, 21, "the sweep reached the widths that must cut it");
    }

    /// The reason is prose and wraps whole to the width — every word of the
    /// long Windows sentence reaches the screen in order, where cutting it
    /// would have left `Windows exposes at most one ACPI…`. Rendered through
    /// `draw_empty_with` so it is checked on every platform, not only where
    /// the long sentence is the platform's own.
    #[test]
    fn a_long_reason_wraps_rather_than_being_cut() {
        use ratatui::Terminal;
        use ratatui::backend::TestBackend;
        const LONG: &str = "Windows exposes at most one ACPI thermal zone, which many \
                            machines do not provide and some provide only to an administrator";
        let config = crate::config::Config::default();
        let hint: Vec<&str> = LONG.split_whitespace().collect();
        let mut whole_seen = 0;
        for (width, height) in [(40u16, 8u16), (30, 8), (24, 10), (12, 3), (6, 1)] {
            let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
            terminal
                .draw(|frame| {
                    TemperaturePanel::draw_empty_with(frame, frame.area(), &config.theme, LONG);
                })
                .unwrap();
            let buffer = terminal.backend().buffer().clone();
            let text: String = (0..height)
                .map(|y| {
                    (0..width)
                        .map(|x| buffer[(x, y)].symbol())
                        .collect::<String>()
                        .trim_end()
                        .to_string()
                })
                .collect::<Vec<_>>()
                .join("\n");
            let shown: Vec<&str> = text.split_whitespace().collect();
            if height >= 6 {
                assert!(
                    shown.windows(hint.len()).any(|w| w == hint.as_slice()),
                    "the whole hint at {width}x{height}:\n{text}"
                );
                whole_seen += 1;
            }
            assert!(
                !text.contains('…') || width < 14,
                "no cut at {width}x{height}:\n{text}"
            );
        }
        assert_eq!(
            whole_seen, 3,
            "the sweep reached the sizes that must show it all"
        );
    }
}