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 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 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 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), Constraint::Length(3), Constraint::Min(4), Constraint::Length(3), Constraint::Length(3), ]
214 .as_ref(),
215 )
216 .split(frame.area());
217
218 let title = Paragraph::new(app.title.as_str()).fg(ratatui::style::Color::Cyan);
220 frame.render_widget(title, chunks[0]);
221
222 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 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 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 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()) } 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 if !app.errors.is_empty() {
311 let last_error = app.errors.last().unwrap();
312 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 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}