1use ratatui::{
2 crossterm::event::{Event, KeyCode, KeyEventKind},
3 layout::{Constraint, Layout, Rect},
4 prelude::*,
5 style::Styled,
6 widgets::{Block, BorderType, Borders, Gauge, Paragraph},
7 Frame,
8};
9
10use crate::{
11 app::AppEvent,
12 framework::info::FrameworkInfo,
13 tui::{
14 component::{AdjustableComponent, AdjustablePanel, Component},
15 control::percentage_control,
16 theme::Theme,
17 },
18};
19
20const NORMAL_CAPACITY_LOSS_MAX: f32 = 0.048;
21const MAX_CHARGE_LIMIT_CONTROL_INDEX: usize = 0;
22
23pub struct ChargePanelComponent(AdjustablePanel);
24
25impl Default for ChargePanelComponent {
26 fn default() -> Self {
27 Self::new()
28 }
29}
30
31impl ChargePanelComponent {
32 pub fn new() -> Self {
33 Self(AdjustablePanel {
34 selected: false,
35 controls: vec![percentage_control(0)],
36 selected_control: MAX_CHARGE_LIMIT_CONTROL_INDEX,
37 })
38 }
39
40 fn render_charge_level(
41 &self,
42 frame: &mut Frame,
43 key_area: Rect,
44 value_area: Rect,
45 theme: &Theme,
46 info: &FrameworkInfo,
47 ) {
48 let gauge = match info.charge_percentage {
49 Some(charge_percentage) => {
50 let gauge_style = if charge_percentage < 15 {
51 Style::default()
52 .fg(theme.indication_warning)
53 .bg(theme.bar_background)
54 } else {
55 Style::default()
56 .fg(theme.indication_ok)
57 .bg(theme.bar_background)
58 };
59 let label = format!("{} {}%", info.charging_status, charge_percentage);
60
61 Gauge::default()
62 .percent(charge_percentage as u16)
63 .label(label)
64 .gauge_style(gauge_style)
65 }
66 None => Gauge::default().percent(0).label("N/A"),
67 };
68
69 frame.render_widget(Paragraph::new("Charge level"), key_area);
70 frame.render_widget(gauge, value_area);
71 }
72
73 fn render_max_charge_limit(
74 &mut self,
75 frame: &mut Frame,
76 key_area: Rect,
77 value_area: Rect,
78 theme: &Theme,
79 info: &FrameworkInfo,
80 ) {
81 let style = self.0.adjustable_control_style(
82 Style::new().fg(theme.background).bg(theme.text),
83 Style::default(),
84 MAX_CHARGE_LIMIT_CONTROL_INDEX,
85 );
86
87 let max_charge_limit = if self
88 .0
89 .is_panel_selected_and_control_focused_by_index(MAX_CHARGE_LIMIT_CONTROL_INDEX)
90 {
91 self.0.get_selected_control().get_percentage_value()
92 } else if let Some(value) = info.max_charge_limit {
93 self.0.set_percentage_control_by_index(
94 MAX_CHARGE_LIMIT_CONTROL_INDEX,
95 percentage_control(value),
96 );
97
98 Some(value)
99 } else {
100 None
101 };
102
103 let gauge = match max_charge_limit {
104 Some(max_charge_limit) => {
105 let style = self.0.adjustable_control_style(
106 Style::default().fg(theme.text).bg(theme.background),
107 Style::default()
108 .fg(theme.charge_bar)
109 .bg(theme.bar_background),
110 MAX_CHARGE_LIMIT_CONTROL_INDEX,
111 );
112 let label = if self
113 .0
114 .is_panel_selected_and_control_focused_by_index(MAX_CHARGE_LIMIT_CONTROL_INDEX)
115 {
116 format!("◀ {:3}% ▶", max_charge_limit)
117 } else {
118 format!("{:3}%", max_charge_limit)
119 };
120
121 Gauge::default()
122 .percent(max_charge_limit as u16)
123 .label(label)
124 .gauge_style(style)
125 }
126 None => Gauge::default().percent(0).label("N/A").gauge_style(style),
127 };
128
129 frame.render_widget(
130 Paragraph::new("Max charge limit").set_style(style),
131 key_area,
132 );
133 frame.render_widget(gauge, value_area);
134 }
135
136 fn render_charger_voltage(
137 &self,
138 frame: &mut Frame,
139 key_area: Rect,
140 value_area: Rect,
141 theme: &Theme,
142 info: &FrameworkInfo,
143 ) {
144 let charger_voltage_text = match info.charger_voltage {
145 Some(charger_voltage) => format!("{} mV", charger_voltage),
146 None => "N/A".to_string(),
147 };
148
149 frame.render_widget(Paragraph::new("Charger voltage"), key_area);
150 frame.render_widget(
151 Paragraph::new(charger_voltage_text).style(Style::default().fg(theme.informative_text)),
152 value_area,
153 );
154 }
155
156 fn render_charger_current(
157 &self,
158 frame: &mut Frame,
159 key_area: Rect,
160 value_area: Rect,
161 theme: &Theme,
162 info: &FrameworkInfo,
163 ) {
164 let charger_current_text = match info.charger_current {
165 Some(charger_current) => format!("{} mA", charger_current),
166 None => "N/A".to_string(),
167 };
168
169 frame.render_widget(Paragraph::new("Charger current"), key_area);
170 frame.render_widget(
171 Paragraph::new(charger_current_text).style(Style::default().fg(theme.informative_text)),
172 value_area,
173 );
174 }
175
176 fn render_design_capacity(
177 &self,
178 frame: &mut Frame,
179 key_area: Rect,
180 value_area: Rect,
181 theme: &Theme,
182 info: &FrameworkInfo,
183 ) {
184 let design_capacity_text = match info.design_capacity {
185 Some(design_capacity) => format!("{} mAh", design_capacity),
186 None => "N/A".to_string(),
187 };
188
189 frame.render_widget(Paragraph::new("Design capacity"), key_area);
190 frame.render_widget(
191 Paragraph::new(design_capacity_text).style(Style::default().fg(theme.informative_text)),
192 value_area,
193 );
194 }
195
196 fn render_last_full_charge_capacity(
197 &self,
198 frame: &mut Frame,
199 key_area: Rect,
200 value_area: Rect,
201 theme: &Theme,
202 info: &FrameworkInfo,
203 ) {
204 let last_full_charge_capacity_text = match info.last_full_charge_capacity {
205 Some(last_full_charge_capacity) => format!("{} mAh", last_full_charge_capacity),
206 None => "N/A".to_string(),
207 };
208
209 frame.render_widget(Paragraph::new("Last full capacity"), key_area);
210 frame.render_widget(
211 Paragraph::new(last_full_charge_capacity_text)
212 .style(Style::default().fg(theme.informative_text)),
213 value_area,
214 );
215 }
216
217 fn render_capacity_loss(
218 &self,
219 frame: &mut Frame,
220 key_area: Rect,
221 value_area: Rect,
222 theme: &Theme,
223 info: &FrameworkInfo,
224 ) {
225 let capacity_loss_text = match info.capacity_loss_percentage {
226 Some(capacity_loss_percentage) => {
227 let inverted = -capacity_loss_percentage;
228
229 format!("{:+.2}%", inverted)
230 }
231 _ => "N/A".to_string(),
232 };
233
234 frame.render_widget(Paragraph::new("Capacity loss"), key_area);
235 frame.render_widget(
236 Paragraph::new(capacity_loss_text).style(Style::default().fg(theme.informative_text)),
237 value_area,
238 );
239 }
240
241 fn render_cycle_count(
242 &self,
243 frame: &mut Frame,
244 key_area: Rect,
245 value_area: Rect,
246 theme: &Theme,
247 info: &FrameworkInfo,
248 ) {
249 let cycle_count_text = match info.cycle_count {
250 Some(cycle_count) => format!("{}", cycle_count),
251 None => "N/A".to_string(),
252 };
253
254 frame.render_widget(Paragraph::new("Cycle count"), key_area);
255 frame.render_widget(
256 Paragraph::new(cycle_count_text).style(Style::default().fg(theme.informative_text)),
257 value_area,
258 );
259 }
260
261 fn render_capacity_loss_per_cycle(
262 &self,
263 frame: &mut Frame,
264 key_area: Rect,
265 value_area: Rect,
266 theme: &Theme,
267 info: &FrameworkInfo,
268 ) {
269 let capacity_loss_per_cycle = info.capacity_loss_per_cycle;
270
271 let capacity_loss_per_cycle_style = match capacity_loss_per_cycle {
272 Some(capacity_loss_per_cycle) => {
273 if capacity_loss_per_cycle < NORMAL_CAPACITY_LOSS_MAX {
274 Style::default().fg(theme.indication_ok)
275 } else {
276 Style::default().fg(theme.indication_warning)
277 }
278 }
279 None => Style::default(),
280 };
281 let capacity_loss_per_cycle_text = match capacity_loss_per_cycle {
282 Some(capacity_loss_per_cycle) => {
283 let inverted = -capacity_loss_per_cycle;
284
285 if capacity_loss_per_cycle > NORMAL_CAPACITY_LOSS_MAX {
286 format!("{:+.3}% (expected 0.025-0.048%)", inverted)
287 } else {
288 format!("{:+.3}%", inverted)
289 }
290 }
291 _ => "N/A".to_string(),
292 };
293
294 frame.render_widget(Paragraph::new("Capacity loss per cycle"), key_area);
295 frame.render_widget(
296 Paragraph::new(capacity_loss_per_cycle_text).style(capacity_loss_per_cycle_style),
297 value_area,
298 );
299 }
300}
301
302impl AdjustableComponent for ChargePanelComponent {
303 fn panel(&mut self) -> &mut AdjustablePanel {
304 &mut self.0
305 }
306}
307
308impl Component for ChargePanelComponent {
309 fn handle_input(&mut self, event: Event) -> Option<crate::app::AppEvent> {
310 let mut app_event = None;
311
312 if self.0.is_selected() {
313 if let Event::Key(key) = event {
314 if key.kind == KeyEventKind::Press {
315 match key.code {
316 KeyCode::Down => self.0.cycle_controls_down(),
317 KeyCode::Up => self.0.cycle_controls_up(),
318 KeyCode::Enter => {
319 match self.0.get_selected_and_focused_control() {
320 Some(control)
321 if self.0.selected_control
322 == MAX_CHARGE_LIMIT_CONTROL_INDEX =>
323 {
324 if let Some(value) = control.get_percentage_value() {
325 app_event = Some(AppEvent::SetMaxChargeLimit(value));
326 }
327 }
328 _ => {}
329 }
330
331 self.0.toggle_selected_control_focus()
332 }
333 KeyCode::Left => self.0.adjust_focused_percentage_control_by_delta(-5),
334 KeyCode::Right => self.0.adjust_focused_percentage_control_by_delta(5),
335 KeyCode::Esc => self.0.toggle_selected_control_focus(),
336 _ => {}
337 }
338 }
339 }
340 }
341
342 app_event
343 }
344
345 fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme, info: &FrameworkInfo) {
346 let block = Block::default()
347 .title(" Charge ")
348 .borders(Borders::ALL)
349 .border_type(BorderType::Rounded)
350 .border_style(self.0.borders_style(theme));
351
352 let [keys_area, values_area] =
353 Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)])
354 .horizontal_margin(2)
355 .vertical_margin(1)
356 .areas(block.inner(area));
357
358 let keys_block = Block::default().borders(Borders::NONE);
359 let values_block = Block::default().borders(Borders::NONE);
360
361 let [charge_level_key_area, _empty1_key_area, charge_limit_key_area, _empty2_key_area, charger_voltage_key_area, charger_current_key_area, design_capacity_key_area, last_full_capacity_key_area, capacity_loss_key_area, cycle_count_key_area, capacity_loss_per_cycle_key_area] =
362 Layout::vertical([
363 Constraint::Length(1),
364 Constraint::Length(1),
365 Constraint::Length(1),
366 Constraint::Length(1),
367 Constraint::Length(1),
368 Constraint::Length(1),
369 Constraint::Length(1),
370 Constraint::Length(1),
371 Constraint::Length(1),
372 Constraint::Length(1),
373 Constraint::Length(1),
374 ])
375 .areas(keys_block.inner(keys_area));
376 let [charge_level_value_area, _empty1_value_area, charge_limit_value_area, _empty2_value_area, charger_voltage_value_area, charger_current_value_area, design_capacity_value_area, last_full_capacity_value_area, capacity_loss_value_area, cycle_count_value_area, capacity_loss_per_cycle_value_area] =
377 Layout::vertical([
378 Constraint::Length(1),
379 Constraint::Length(1),
380 Constraint::Length(1),
381 Constraint::Length(1),
382 Constraint::Length(1),
383 Constraint::Length(1),
384 Constraint::Length(1),
385 Constraint::Length(1),
386 Constraint::Length(1),
387 Constraint::Length(1),
388 Constraint::Length(1),
389 ])
390 .horizontal_margin(1)
391 .areas(values_block.inner(values_area));
392
393 self.render_charge_level(
395 frame,
396 charge_level_key_area,
397 charge_level_value_area,
398 theme,
399 info,
400 );
401
402 self.render_max_charge_limit(
404 frame,
405 charge_limit_key_area,
406 charge_limit_value_area,
407 theme,
408 info,
409 );
410
411 self.render_charger_voltage(
413 frame,
414 charger_voltage_key_area,
415 charger_voltage_value_area,
416 theme,
417 info,
418 );
419
420 self.render_charger_current(
422 frame,
423 charger_current_key_area,
424 charger_current_value_area,
425 theme,
426 info,
427 );
428
429 self.render_design_capacity(
431 frame,
432 design_capacity_key_area,
433 design_capacity_value_area,
434 theme,
435 info,
436 );
437
438 self.render_last_full_charge_capacity(
440 frame,
441 last_full_capacity_key_area,
442 last_full_capacity_value_area,
443 theme,
444 info,
445 );
446
447 self.render_capacity_loss(
449 frame,
450 capacity_loss_key_area,
451 capacity_loss_value_area,
452 theme,
453 info,
454 );
455
456 self.render_cycle_count(
458 frame,
459 cycle_count_key_area,
460 cycle_count_value_area,
461 theme,
462 info,
463 );
464
465 self.render_capacity_loss_per_cycle(
467 frame,
468 capacity_loss_per_cycle_key_area,
469 capacity_loss_per_cycle_value_area,
470 theme,
471 info,
472 );
473
474 frame.render_widget(keys_block, keys_area);
476 frame.render_widget(values_block, values_area);
477
478 frame.render_widget(block, area);
479 }
480}
481
482#[cfg(test)]
483mod tests {
484 use ratatui::crossterm::event::{Event, KeyCode, KeyEvent};
485
486 use crate::tui::{
487 component::{
488 charge_panel::{ChargePanelComponent, MAX_CHARGE_LIMIT_CONTROL_INDEX},
489 Component,
490 },
491 control::AdjustableControl,
492 };
493
494 #[test]
495 fn handle_input_enter_when_panel_selected() {
496 let mut panel = ChargePanelComponent::new();
497 let event = Event::Key(KeyEvent::from(KeyCode::Enter));
498
499 panel.0.toggle();
500 let _ = panel.handle_input(event);
501
502 assert!(panel.0.is_selected());
503 assert!(panel.0.controls.len() == 1);
504 assert!(panel.0.controls[MAX_CHARGE_LIMIT_CONTROL_INDEX].is_focused())
505 }
506
507 #[test]
508 fn handle_input_left_for_focused_percentage_control_stay_in_range() {
509 let mut panel = ChargePanelComponent::new();
510 let event = Event::Key(KeyEvent::from(KeyCode::Left));
511
512 panel.0.toggle();
513 panel.0.toggle_selected_control_focus();
514 let _ = panel.handle_input(event);
515
516 assert!(panel.0.is_selected());
517 assert!(panel.0.controls.len() == 1);
518 assert!(panel
519 .0
520 .is_panel_selected_and_control_focused_by_index(MAX_CHARGE_LIMIT_CONTROL_INDEX));
521 assert!(matches!(
522 panel.0.get_selected_control(),
523 AdjustableControl::Percentage(true, 0)
524 ));
525 }
526}