use ratatui::{
crossterm::event::{Event, KeyCode, KeyEventKind},
layout::{Constraint, Layout, Rect},
prelude::*,
style::Styled,
widgets::{Block, BorderType, Borders, Gauge, Paragraph},
Frame,
};
use crate::{
app::AppEvent,
framework::info::FrameworkInfo,
tui::{
component::{AdjustableComponent, AdjustablePanel, Component},
control::percentage_control,
theme::Theme,
},
};
const NORMAL_CAPACITY_LOSS_MAX: f32 = 0.048;
const MAX_CHARGE_LIMIT_CONTROL_INDEX: usize = 0;
pub struct ChargePanelComponent(AdjustablePanel);
impl Default for ChargePanelComponent {
fn default() -> Self {
Self::new()
}
}
impl ChargePanelComponent {
pub fn new() -> Self {
Self(AdjustablePanel {
selected: false,
controls: vec![percentage_control(0)],
selected_control: MAX_CHARGE_LIMIT_CONTROL_INDEX,
})
}
fn render_charge_level(
&self,
frame: &mut Frame,
key_area: Rect,
value_area: Rect,
theme: &Theme,
info: &FrameworkInfo,
) {
let gauge = match info.charge_percentage {
Some(charge_percentage) => {
let gauge_style = if charge_percentage < 15 {
Style::default()
.fg(theme.indication_warning)
.bg(theme.bar_background)
} else {
Style::default()
.fg(theme.indication_ok)
.bg(theme.bar_background)
};
let label = format!("{} {}%", info.charging_status, charge_percentage);
Gauge::default()
.percent(charge_percentage as u16)
.label(label)
.gauge_style(gauge_style)
}
None => Gauge::default().percent(0).label("N/A"),
};
frame.render_widget(Paragraph::new("Charge level"), key_area);
frame.render_widget(gauge, value_area);
}
fn render_max_charge_limit(
&mut self,
frame: &mut Frame,
key_area: Rect,
value_area: Rect,
theme: &Theme,
info: &FrameworkInfo,
) {
let style = self.0.adjustable_control_style(
Style::new().fg(theme.background).bg(theme.text),
Style::default(),
MAX_CHARGE_LIMIT_CONTROL_INDEX,
);
let max_charge_limit = if self
.0
.is_panel_selected_and_control_focused_by_index(MAX_CHARGE_LIMIT_CONTROL_INDEX)
{
self.0.get_selected_control().get_percentage_value()
} else if let Some(value) = info.max_charge_limit {
self.0.set_percentage_control_by_index(
MAX_CHARGE_LIMIT_CONTROL_INDEX,
percentage_control(value),
);
Some(value)
} else {
None
};
let gauge = match max_charge_limit {
Some(max_charge_limit) => {
let style = self.0.adjustable_control_style(
Style::default().fg(theme.text).bg(theme.background),
Style::default()
.fg(theme.charge_bar)
.bg(theme.bar_background),
MAX_CHARGE_LIMIT_CONTROL_INDEX,
);
let label = if self
.0
.is_panel_selected_and_control_focused_by_index(MAX_CHARGE_LIMIT_CONTROL_INDEX)
{
format!("â—€ {:3}% â–¶", max_charge_limit)
} else {
format!("{:3}%", max_charge_limit)
};
Gauge::default()
.percent(max_charge_limit as u16)
.label(label)
.gauge_style(style)
}
None => Gauge::default().percent(0).label("N/A").gauge_style(style),
};
frame.render_widget(
Paragraph::new("Max charge limit").set_style(style),
key_area,
);
frame.render_widget(gauge, value_area);
}
fn render_charger_voltage(
&self,
frame: &mut Frame,
key_area: Rect,
value_area: Rect,
theme: &Theme,
info: &FrameworkInfo,
) {
let charger_voltage_text = match info.charger_voltage {
Some(charger_voltage) => format!("{} mV", charger_voltage),
None => "N/A".to_string(),
};
frame.render_widget(Paragraph::new("Charger voltage"), key_area);
frame.render_widget(
Paragraph::new(charger_voltage_text).style(Style::default().fg(theme.informative_text)),
value_area,
);
}
fn render_charger_current(
&self,
frame: &mut Frame,
key_area: Rect,
value_area: Rect,
theme: &Theme,
info: &FrameworkInfo,
) {
let charger_current_text = match info.charger_current {
Some(charger_current) => format!("{} mA", charger_current),
None => "N/A".to_string(),
};
frame.render_widget(Paragraph::new("Charger current"), key_area);
frame.render_widget(
Paragraph::new(charger_current_text).style(Style::default().fg(theme.informative_text)),
value_area,
);
}
fn render_design_capacity(
&self,
frame: &mut Frame,
key_area: Rect,
value_area: Rect,
theme: &Theme,
info: &FrameworkInfo,
) {
let design_capacity_text = match info.design_capacity {
Some(design_capacity) => format!("{} mAh", design_capacity),
None => "N/A".to_string(),
};
frame.render_widget(Paragraph::new("Design capacity"), key_area);
frame.render_widget(
Paragraph::new(design_capacity_text).style(Style::default().fg(theme.informative_text)),
value_area,
);
}
fn render_last_full_charge_capacity(
&self,
frame: &mut Frame,
key_area: Rect,
value_area: Rect,
theme: &Theme,
info: &FrameworkInfo,
) {
let last_full_charge_capacity_text = match info.last_full_charge_capacity {
Some(last_full_charge_capacity) => format!("{} mAh", last_full_charge_capacity),
None => "N/A".to_string(),
};
frame.render_widget(Paragraph::new("Last full capacity"), key_area);
frame.render_widget(
Paragraph::new(last_full_charge_capacity_text)
.style(Style::default().fg(theme.informative_text)),
value_area,
);
}
fn render_capacity_loss(
&self,
frame: &mut Frame,
key_area: Rect,
value_area: Rect,
theme: &Theme,
info: &FrameworkInfo,
) {
let capacity_loss_text = match info.capacity_loss_percentage {
Some(capacity_loss_percentage) => {
let inverted = -capacity_loss_percentage;
format!("{:+.2}%", inverted)
}
_ => "N/A".to_string(),
};
frame.render_widget(Paragraph::new("Capacity loss"), key_area);
frame.render_widget(
Paragraph::new(capacity_loss_text).style(Style::default().fg(theme.informative_text)),
value_area,
);
}
fn render_cycle_count(
&self,
frame: &mut Frame,
key_area: Rect,
value_area: Rect,
theme: &Theme,
info: &FrameworkInfo,
) {
let cycle_count_text = match info.cycle_count {
Some(cycle_count) => format!("{}", cycle_count),
None => "N/A".to_string(),
};
frame.render_widget(Paragraph::new("Cycle count"), key_area);
frame.render_widget(
Paragraph::new(cycle_count_text).style(Style::default().fg(theme.informative_text)),
value_area,
);
}
fn render_capacity_loss_per_cycle(
&self,
frame: &mut Frame,
key_area: Rect,
value_area: Rect,
theme: &Theme,
info: &FrameworkInfo,
) {
let capacity_loss_per_cycle = info.capacity_loss_per_cycle;
let capacity_loss_per_cycle_style = match capacity_loss_per_cycle {
Some(capacity_loss_per_cycle) => {
if capacity_loss_per_cycle < NORMAL_CAPACITY_LOSS_MAX {
Style::default().fg(theme.indication_ok)
} else {
Style::default().fg(theme.indication_warning)
}
}
None => Style::default(),
};
let capacity_loss_per_cycle_text = match capacity_loss_per_cycle {
Some(capacity_loss_per_cycle) => {
let inverted = -capacity_loss_per_cycle;
if capacity_loss_per_cycle > NORMAL_CAPACITY_LOSS_MAX {
format!("{:+.3}% (expected 0.025-0.048%)", inverted)
} else {
format!("{:+.3}%", inverted)
}
}
_ => "N/A".to_string(),
};
frame.render_widget(Paragraph::new("Capacity loss per cycle"), key_area);
frame.render_widget(
Paragraph::new(capacity_loss_per_cycle_text).style(capacity_loss_per_cycle_style),
value_area,
);
}
}
impl AdjustableComponent for ChargePanelComponent {
fn panel(&mut self) -> &mut AdjustablePanel {
&mut self.0
}
}
impl Component for ChargePanelComponent {
fn handle_input(&mut self, event: Event) -> Option<crate::app::AppEvent> {
let mut app_event = None;
if self.0.is_selected() {
if let Event::Key(key) = event {
if key.kind == KeyEventKind::Press {
match key.code {
KeyCode::Down => self.0.cycle_controls_down(),
KeyCode::Up => self.0.cycle_controls_up(),
KeyCode::Enter => {
match self.0.get_selected_and_focused_control() {
Some(control)
if self.0.selected_control
== MAX_CHARGE_LIMIT_CONTROL_INDEX =>
{
if let Some(value) = control.get_percentage_value() {
app_event = Some(AppEvent::SetMaxChargeLimit(value));
}
}
_ => {}
}
self.0.toggle_selected_control_focus()
}
KeyCode::Left => self.0.adjust_focused_percentage_control_by_delta(-5),
KeyCode::Right => self.0.adjust_focused_percentage_control_by_delta(5),
KeyCode::Esc => self.0.toggle_selected_control_focus(),
_ => {}
}
}
}
}
app_event
}
fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme, info: &FrameworkInfo) {
let block = Block::default()
.title(" Charge ")
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(self.0.borders_style(theme));
let [keys_area, values_area] =
Layout::horizontal([Constraint::Fill(1), Constraint::Fill(1)])
.horizontal_margin(2)
.vertical_margin(1)
.areas(block.inner(area));
let keys_block = Block::default().borders(Borders::NONE);
let values_block = Block::default().borders(Borders::NONE);
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] =
Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.areas(keys_block.inner(keys_area));
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] =
Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.horizontal_margin(1)
.areas(values_block.inner(values_area));
self.render_charge_level(
frame,
charge_level_key_area,
charge_level_value_area,
theme,
info,
);
self.render_max_charge_limit(
frame,
charge_limit_key_area,
charge_limit_value_area,
theme,
info,
);
self.render_charger_voltage(
frame,
charger_voltage_key_area,
charger_voltage_value_area,
theme,
info,
);
self.render_charger_current(
frame,
charger_current_key_area,
charger_current_value_area,
theme,
info,
);
self.render_design_capacity(
frame,
design_capacity_key_area,
design_capacity_value_area,
theme,
info,
);
self.render_last_full_charge_capacity(
frame,
last_full_capacity_key_area,
last_full_capacity_value_area,
theme,
info,
);
self.render_capacity_loss(
frame,
capacity_loss_key_area,
capacity_loss_value_area,
theme,
info,
);
self.render_cycle_count(
frame,
cycle_count_key_area,
cycle_count_value_area,
theme,
info,
);
self.render_capacity_loss_per_cycle(
frame,
capacity_loss_per_cycle_key_area,
capacity_loss_per_cycle_value_area,
theme,
info,
);
frame.render_widget(keys_block, keys_area);
frame.render_widget(values_block, values_area);
frame.render_widget(block, area);
}
}
#[cfg(test)]
mod tests {
use ratatui::crossterm::event::{Event, KeyCode, KeyEvent};
use crate::tui::{
component::{
charge_panel::{ChargePanelComponent, MAX_CHARGE_LIMIT_CONTROL_INDEX},
Component,
},
control::AdjustableControl,
};
#[test]
fn handle_input_enter_when_panel_selected() {
let mut panel = ChargePanelComponent::new();
let event = Event::Key(KeyEvent::from(KeyCode::Enter));
panel.0.toggle();
let _ = panel.handle_input(event);
assert!(panel.0.is_selected());
assert!(panel.0.controls.len() == 1);
assert!(panel.0.controls[MAX_CHARGE_LIMIT_CONTROL_INDEX].is_focused())
}
#[test]
fn handle_input_left_for_focused_percentage_control_stay_in_range() {
let mut panel = ChargePanelComponent::new();
let event = Event::Key(KeyEvent::from(KeyCode::Left));
panel.0.toggle();
panel.0.toggle_selected_control_focus();
let _ = panel.handle_input(event);
assert!(panel.0.is_selected());
assert!(panel.0.controls.len() == 1);
assert!(panel
.0
.is_panel_selected_and_control_focused_by_index(MAX_CHARGE_LIMIT_CONTROL_INDEX));
assert!(matches!(
panel.0.get_selected_control(),
AdjustableControl::Percentage(true, 0)
));
}
}