Skip to main content

cpu_temp/
ui.rs

1use std::borrow::Cow;
2
3use crossbeam_channel::{Receiver, TryRecvError};
4use ratatui::prelude::Stylize;
5use ratatui::{
6    backend::CrosstermBackend,
7    layout::{Constraint, Direction, Layout},
8    widgets::{Block, Borders, Cell, Paragraph, Row, Table},
9    Frame, Terminal,
10};
11
12use crossterm::{
13    event::{self, Event, KeyCode, KeyEventKind},
14    terminal::{
15        disable_raw_mode, enable_raw_mode, Clear, ClearType, EnterAlternateScreen,
16        LeaveAlternateScreen,
17    },
18    ExecutableCommand,
19};
20
21use crate::cpu::info::CpuInfo;
22
23use crate::cpu::intel::TemperatureData;
24use crate::ui::TemperatureMessage::InitError;
25
26pub enum TemperatureMessage {
27    InitError(String),
28    RuntimeError(anyhow::Error),
29    Ok(TemperatureData),
30}
31
32impl TemperatureMessage {
33    pub fn init_error(&self) -> Option<&str> {
34        match self {
35            InitError(e) => Some(e.as_str()),
36            _ => None,
37        }
38    }
39}
40
41#[derive(Debug)]
42pub struct CpuTempApp {
43    pub title: String,
44    pub cpu_name: String,
45    pub data: anyhow::Result<TemperatureData>,
46    pub errors: Vec<String>,
47    pub running: bool,
48}
49
50impl Default for CpuTempApp {
51    fn default() -> Self {
52        let cpu_name = CpuInfo::get_core_cpu_mapping()
53            .map(|m| {
54                m.iter()
55                    .filter_map(|c| c.first().copied())
56                    .collect::<Vec<_>>()
57            })
58            .unwrap_or_default();
59
60        Self {
61            title: "CPU Temperature Monitor".to_string(),
62            cpu_name: format!("{} ({} cores)", "Unknown CPU", cpu_name.len()),
63            data: Err(anyhow::anyhow!("Waiting")),
64            errors: Vec::new(),
65            running: true,
66        }
67    }
68}
69
70impl CpuTempApp {
71    pub fn run_ui(&mut self) -> anyhow::Result<()> {
72        enable_raw_mode()?;
73        std::io::stderr().execute(EnterAlternateScreen)?;
74        std::io::stderr().execute(Clear(ClearType::All))?;
75        let mut terminal = Terminal::new(CrosstermBackend::new(std::io::stderr()))?;
76
77        let result = run_app(&mut terminal, self);
78
79        disable_raw_mode()?;
80        std::io::stderr().execute(LeaveAlternateScreen)?;
81
82        result
83    }
84
85    pub fn run_with_data_receiver(
86        &mut self,
87        rx: Receiver<TemperatureMessage>,
88    ) -> anyhow::Result<()> {
89        enable_raw_mode()?;
90        std::io::stderr().execute(EnterAlternateScreen)?;
91        std::io::stderr().execute(Clear(ClearType::All))?;
92        let mut terminal = Terminal::new(CrosstermBackend::new(std::io::stderr()))?;
93
94        let result = run_app_with_receiver(&mut terminal, self, rx);
95
96        disable_raw_mode()?;
97        std::io::stderr().execute(LeaveAlternateScreen)?;
98
99        result
100    }
101}
102
103fn run_app<B: std::io::Write>(
104    terminal: &mut Terminal<CrosstermBackend<B>>,
105    app: &mut CpuTempApp,
106) -> anyhow::Result<()> {
107    loop {
108        terminal.draw(|frame| ui(frame, app))?;
109
110        if !app.running {
111            break;
112        }
113
114        // Check for exit key (Ctrl+C or Escape)
115        if event::poll(std::time::Duration::from_millis(100))? {
116            if let Event::Key(key) = event::read()? {
117                if key.kind == KeyEventKind::Press {
118                    match key.code {
119                        KeyCode::Char('c')
120                            if key
121                                .modifiers
122                                .contains(crossterm::event::KeyModifiers::CONTROL) =>
123                        {
124                            app.running = false;
125                            break;
126                        }
127                        KeyCode::Esc => {
128                            app.running = false;
129                            break;
130                        }
131                        _ => {}
132                    }
133                }
134            }
135        }
136    }
137
138    Ok(())
139}
140
141fn run_app_with_receiver<B: std::io::Write>(
142    terminal: &mut Terminal<CrosstermBackend<B>>,
143    app: &mut CpuTempApp,
144    rx: Receiver<TemperatureMessage>,
145) -> anyhow::Result<()> {
146    let mut init_error = false;
147    loop {
148        // 尝试从通道接收温度数据
149        if !init_error {
150            match rx.try_recv() {
151                Ok(msg) => {
152                    app.data = match msg {
153                        InitError(e) => {
154                            init_error = true;
155                            Err(anyhow::anyhow!(e))
156                        }
157                        TemperatureMessage::RuntimeError(e) => Err(e),
158                        TemperatureMessage::Ok(d) => Ok(d),
159                    };
160                }
161                Err(TryRecvError::Empty) => {
162                    std::thread::yield_now();
163                    continue;
164                }
165                Err(TryRecvError::Disconnected) => {}
166            };
167        }
168
169        terminal.draw(|frame| ui(frame, app))?;
170
171        if !app.running {
172            break;
173        }
174
175        // Check for exit key (Ctrl+C or Escape)
176        if event::poll(std::time::Duration::from_millis(100))? {
177            if let Event::Key(key) = event::read()? {
178                if key.kind == KeyEventKind::Press {
179                    match key.code {
180                        KeyCode::Char('c')
181                            if key
182                                .modifiers
183                                .contains(crossterm::event::KeyModifiers::CONTROL) =>
184                        {
185                            app.running = false;
186                            break;
187                        }
188                        KeyCode::Esc => {
189                            app.running = false;
190                            break;
191                        }
192                        _ => {}
193                    }
194                }
195            }
196        }
197    }
198
199    Ok(())
200}
201
202fn ui(frame: &mut Frame<'_>, app: &mut CpuTempApp) {
203    let chunks = Layout::default()
204        .direction(Direction::Vertical)
205        .margin(1)
206        .constraints(
207            [
208                Constraint::Length(1), // Title
209                Constraint::Length(3), // CPU Info
210                Constraint::Min(4),    // Core temperatures table
211                Constraint::Length(3), // Package temperature
212                Constraint::Length(3), // Status bar
213            ]
214            .as_ref(),
215        )
216        .split(frame.area());
217
218    // Title
219    let title = Paragraph::new(app.title.as_str()).fg(ratatui::style::Color::Cyan);
220    frame.render_widget(title, chunks[0]);
221
222    // CPU Info
223    let cpu_info = Paragraph::new(app.cpu_name.as_str())
224        .block(Block::default().borders(Borders::ALL).title(" CPU Info "));
225    frame.render_widget(cpu_info, chunks[1]);
226
227    // Core temperatures table
228    let header = Row::new(vec![
229        Cell::from("Core"),
230        Cell::from("Temperature (°C)"),
231        Cell::from("TjMax (°C)"),
232    ])
233    .bold()
234    .bg(ratatui::style::Color::DarkGray);
235
236    let core_rows: Vec<Row> = if let Ok(data) = &app.data {
237        data.core_temps
238            .iter()
239            .map(|core| {
240                Row::new(vec![
241                    Cell::from(format!("{}", core.physical_id + 1)),
242                    Cell::from(format!("{:.1}", core.core_temp.temperature))
243                        .fg(temp_color(core.core_temp.temperature, data.tj_max)),
244                    Cell::from(format!("{:.1}", core.core_temp.tj_max)),
245                ])
246            })
247            .collect()
248    } else {
249        vec![Row::new(vec![
250            Cell::from("N/A"),
251            Cell::from("N/A"),
252            Cell::from("N/A"),
253        ])]
254    };
255
256    let core_table = Table::new(
257        core_rows,
258        [
259            Constraint::Length(6),
260            Constraint::Length(18),
261            Constraint::Length(10),
262            Constraint::Length(12),
263        ],
264    )
265    .header(header)
266    .block(
267        Block::default()
268            .borders(Borders::ALL)
269            .title(" Core Temperatures "),
270    );
271
272    frame.render_widget(core_table, chunks[2]);
273
274    // Package temperature
275    let package_temp_text = match &app.data {
276        Ok(data) => match &data.package_temp {
277            Ok(pkg) => format!(
278                "Package: {:.1}°C | TjMax: {:.1}°C",
279                pkg.temperature, pkg.tj_max
280            ),
281            Err(e) => format!("Package: {}", e),
282        },
283        Err(_) => "Package: N/A".to_string(),
284    };
285
286    let package_temp = Paragraph::new(package_temp_text.as_str())
287        .block(
288            Block::default()
289                .borders(Borders::ALL)
290                .title(" Package Temperature "),
291        )
292        .fg(ratatui::style::Color::Yellow);
293    frame.render_widget(package_temp, chunks[3]);
294
295    // Status bar - 始终渲染在最底层
296    let status_text: Cow<'static, str> = if !app.running {
297        Cow::Borrowed("Exiting...")
298    } else if let Err(e) = &app.data {
299        Cow::Owned(e.to_string()) // 只有在出错时才进行堆分配
300    } else {
301        Cow::Borrowed("Press Ctrl+C or Esc to exit")
302    };
303
304    let status = Paragraph::new(status_text)
305        .block(Block::default().borders(Borders::ALL).title(" Status "))
306        .fg(ratatui::style::Color::Green);
307    frame.render_widget(status, chunks[4]);
308
309    // Errors display - 在status bar上覆盖显示错误信息(不遮挡整个状态栏)
310    if !app.errors.is_empty() {
311        let last_error = app.errors.last().unwrap();
312        // 只截取前50个字符显示,避免过长
313        let error_display = if last_error.len() > 50 {
314            &last_error[..50]
315        } else {
316            last_error.as_str()
317        };
318        let error_msg = Paragraph::new(error_display)
319            .block(Block::default().borders(Borders::NONE).title(" Error "))
320            .fg(ratatui::style::Color::Red);
321
322        // 在status bar内部左侧显示错误
323        frame.render_widget(error_msg, chunks[4]);
324    }
325}
326
327fn temp_color(temp: f32, tj_max: f32) -> ratatui::style::Color {
328    let ratio = if tj_max > 0.0 { temp / tj_max } else { 0.5 };
329
330    match ratio {
331        r if r < 0.6 => ratatui::style::Color::Green,
332        r if r < 0.75 => ratatui::style::Color::Yellow,
333        r if r < 0.9 => ratatui::style::Color::Rgb(255, 165, 0),
334        _ => ratatui::style::Color::Red,
335    }
336}