Skip to main content

framework_tool_tui/tui/component/
charge_panel.rs

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        // Charge level
394        self.render_charge_level(
395            frame,
396            charge_level_key_area,
397            charge_level_value_area,
398            theme,
399            info,
400        );
401
402        // Max charge limit
403        self.render_max_charge_limit(
404            frame,
405            charge_limit_key_area,
406            charge_limit_value_area,
407            theme,
408            info,
409        );
410
411        // Charger voltage
412        self.render_charger_voltage(
413            frame,
414            charger_voltage_key_area,
415            charger_voltage_value_area,
416            theme,
417            info,
418        );
419
420        // Charger current
421        self.render_charger_current(
422            frame,
423            charger_current_key_area,
424            charger_current_value_area,
425            theme,
426            info,
427        );
428
429        // Design capacity
430        self.render_design_capacity(
431            frame,
432            design_capacity_key_area,
433            design_capacity_value_area,
434            theme,
435            info,
436        );
437
438        // Last full charge capacity
439        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        // Capacity loss
448        self.render_capacity_loss(
449            frame,
450            capacity_loss_key_area,
451            capacity_loss_value_area,
452            theme,
453            info,
454        );
455
456        // Cycle count
457        self.render_cycle_count(
458            frame,
459            cycle_count_key_area,
460            cycle_count_value_area,
461            theme,
462            info,
463        );
464
465        // Capacity loss per cycle
466        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        // Render blocks
475        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}