use crate::icon::BuiltinIcon;
use crate::render::{Bounds, RenderContext};
use super::{
Msg, ResolvedIcon, StateColors, Widget, WidgetStyle, draw_icon_content, measure_icon_content,
};
pub fn validate_battery_format(format: &str) -> Result<(), String> {
let mut rest = format;
while !rest.is_empty() {
let next_open = rest.find('{');
let next_close = rest.find('}');
if next_close.is_some_and(|close| next_open.is_none_or(|open| close < open)) {
return Err("contains an unmatched `}`".to_string());
}
let Some(open) = next_open else {
break;
};
rest = &rest[open + 1..];
let Some(close) = rest.find('}') else {
return Err("contains an unmatched `{`".to_string());
};
let placeholder = &rest[..close];
if !matches!(placeholder, "icon" | "percent" | "state") {
return Err(format!(
"contains unsupported placeholder `{{{placeholder}}}`"
));
}
rest = &rest[close + 1..];
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BatteryState {
Charging,
Discharging,
Full,
Unknown,
}
impl BatteryState {
pub fn label(self) -> &'static str {
match self {
BatteryState::Charging => "charging",
BatteryState::Discharging => "discharging",
BatteryState::Full => "full",
BatteryState::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Battery {
state: BatteryState,
percent: u8,
}
impl Battery {
pub fn new(state: BatteryState, percent: f64) -> Self {
let percent = if percent.is_nan() {
0
} else {
percent.clamp(0.0, 100.0).round() as u8
};
Self { state, percent }
}
pub fn state(self) -> BatteryState {
self.state
}
pub fn percent(self) -> u8 {
self.percent
}
pub fn label(self) -> String {
format!("{}% {}", self.percent, self.state.label())
}
}
const LEVEL_LOW: (u8, u8, u8, u8) = (0xF5, 0xC7, 0x0F, 0xFF);
const LEVEL_MID: (u8, u8, u8, u8) = (0xF9, 0xCF, 0x07, 0xFF);
const LEVEL_HIGH: (u8, u8, u8, u8) = (0x2D, 0xF1, 0x85, 0xFF);
fn default_icon(battery: Battery) -> BuiltinIcon {
match battery.state() {
BatteryState::Charging => BuiltinIcon::BatteryCharging,
BatteryState::Full => BuiltinIcon::BatteryFull,
_ => match battery.percent() {
0..=33 => BuiltinIcon::BatteryLow,
34..=66 => BuiltinIcon::BatteryHalf,
_ => BuiltinIcon::BatteryFull,
},
}
}
pub struct BatteryWidget {
bounds: Bounds,
state: Option<Battery>,
style: WidgetStyle,
format: Option<String>,
}
impl BatteryWidget {
pub fn new(bounds: Bounds) -> Self {
Self {
bounds,
state: None,
style: WidgetStyle::default(),
format: None,
}
}
pub fn with_style(mut self, style: WidgetStyle) -> Self {
self.style = style;
self
}
pub fn with_format(mut self, format: Option<String>) -> Self {
self.format = format;
self
}
pub fn label(&self) -> String {
self.state.map(Battery::label).unwrap_or_default()
}
fn template(&self) -> String {
match self.state {
Some(battery) => match &self.format {
Some(format) => format
.replace("{percent}", &battery.percent().to_string())
.replace("{state}", battery.state().label()),
None => format!("{{icon}} {}", battery.label()),
},
None => String::new(),
}
}
fn icon(&self, battery: Battery) -> ResolvedIcon {
self.style.resolve_icon(default_icon(battery))
}
fn state_colors(&self, battery: Battery) -> StateColors {
if battery.state() == BatteryState::Charging {
self.style.charging
} else if battery.state() == BatteryState::Discharging
&& u32::from(battery.percent()) < self.style.warn_threshold
{
self.style.warn
} else {
self.level_colors(battery.percent())
}
}
fn level_colors(&self, percent: u8) -> StateColors {
let foreground = match percent {
0..=40 => LEVEL_LOW,
41..=70 => LEVEL_MID,
_ => LEVEL_HIGH,
};
StateColors {
foreground,
..self.style.base_colors()
}
}
}
impl Widget for BatteryWidget {
fn update(&mut self, msg: &Msg) -> bool {
match msg {
Msg::Battery(next) => {
if &self.state == next {
return false;
}
self.state = *next;
true
}
_ => false,
}
}
fn draw(&self, ctx: &mut RenderContext) {
if let Some(battery) = self.state {
draw_icon_content(
ctx,
&self.style,
self.bounds,
&self.icon(battery),
&self.template(),
self.state_colors(battery),
);
}
}
fn measure(&self, ctx: &mut RenderContext, _height: u32) -> u32 {
match self.state {
Some(battery) => {
measure_icon_content(ctx, &self.style, &self.icon(battery), &self.template())
}
None => 0,
}
}
fn bounds(&self) -> Bounds {
self.bounds
}
fn set_bounds(&mut self, bounds: Bounds) {
self.bounds = bounds;
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Local, TimeZone};
fn battery(state: BatteryState, percent: f64) -> Msg {
Msg::Battery(Some(Battery::new(state, percent)))
}
#[test]
fn new_rounds_percentage_to_whole_percent() {
assert_eq!(Battery::new(BatteryState::Discharging, 84.6).percent(), 85);
assert_eq!(Battery::new(BatteryState::Discharging, 84.4).percent(), 84);
}
#[test]
fn new_clamps_out_of_range_percentages() {
assert_eq!(Battery::new(BatteryState::Full, 137.0).percent(), 100);
assert_eq!(Battery::new(BatteryState::Discharging, -5.0).percent(), 0);
}
#[test]
fn new_treats_nan_percentage_as_zero() {
assert_eq!(Battery::new(BatteryState::Unknown, f64::NAN).percent(), 0);
}
#[test]
fn state_labels_are_unambiguous_words() {
assert_eq!(BatteryState::Charging.label(), "charging");
assert_eq!(BatteryState::Discharging.label(), "discharging");
assert_eq!(BatteryState::Full.label(), "full");
assert_eq!(BatteryState::Unknown.label(), "unknown");
}
#[test]
fn label_joins_percentage_and_state() {
assert_eq!(
Battery::new(BatteryState::Discharging, 85.0).label(),
"85% discharging"
);
assert_eq!(
Battery::new(BatteryState::Charging, 12.0).label(),
"12% charging"
);
}
#[test]
fn first_reading_changes_state_and_sets_label() {
let mut widget = BatteryWidget::new(Bounds::new(0, 0, 320, 32));
assert_eq!(widget.label(), "");
assert!(widget.update(&battery(BatteryState::Discharging, 85.0)));
assert_eq!(widget.label(), "85% discharging");
}
#[test]
fn identical_reading_is_not_a_visible_change() {
let mut widget = BatteryWidget::new(Bounds::new(0, 0, 320, 32));
assert!(widget.update(&battery(BatteryState::Discharging, 85.0)));
assert!(!widget.update(&battery(BatteryState::Discharging, 85.2)));
assert_eq!(widget.label(), "85% discharging");
}
#[test]
fn a_new_percentage_is_a_visible_change() {
let mut widget = BatteryWidget::new(Bounds::new(0, 0, 320, 32));
assert!(widget.update(&battery(BatteryState::Discharging, 85.0)));
assert!(widget.update(&battery(BatteryState::Discharging, 84.0)));
assert_eq!(widget.label(), "84% discharging");
}
#[test]
fn a_new_state_is_a_visible_change() {
let mut widget = BatteryWidget::new(Bounds::new(0, 0, 320, 32));
assert!(widget.update(&battery(BatteryState::Discharging, 85.0)));
assert!(widget.update(&battery(BatteryState::Charging, 85.0)));
assert_eq!(widget.label(), "85% charging");
}
#[test]
fn battery_going_absent_is_a_visible_change_then_blank() {
let mut widget = BatteryWidget::new(Bounds::new(0, 0, 320, 32));
assert!(widget.update(&battery(BatteryState::Discharging, 85.0)));
assert!(widget.update(&Msg::Battery(None)));
assert_eq!(widget.label(), "");
}
#[test]
fn absent_reading_before_any_battery_is_not_a_change() {
let mut widget = BatteryWidget::new(Bounds::new(0, 0, 320, 32));
assert!(!widget.update(&Msg::Battery(None)));
assert_eq!(widget.label(), "");
}
#[test]
fn unrelated_message_is_ignored() {
let mut widget = BatteryWidget::new(Bounds::new(0, 0, 320, 32));
widget.update(&battery(BatteryState::Full, 100.0));
let tick = Msg::Tick(Local.with_ymd_and_hms(2026, 6, 27, 8, 0, 0).unwrap());
assert!(!widget.update(&tick));
assert_eq!(widget.label(), "100% full");
}
#[test]
fn set_bounds_repositions_the_widget() {
let mut widget = BatteryWidget::new(Bounds::new(0, 0, 1, 1));
widget.set_bounds(Bounds::new(10, 0, 200, 32));
assert_eq!(widget.bounds(), Bounds::new(10, 0, 200, 32));
}
#[test]
fn default_icon_ramps_by_tercile_while_discharging() {
let icon = |percent| default_icon(Battery::new(BatteryState::Discharging, percent));
assert_eq!(icon(0.0), BuiltinIcon::BatteryLow);
assert_eq!(icon(33.0), BuiltinIcon::BatteryLow);
assert_eq!(icon(34.0), BuiltinIcon::BatteryHalf);
assert_eq!(icon(66.0), BuiltinIcon::BatteryHalf);
assert_eq!(icon(67.0), BuiltinIcon::BatteryFull);
assert_eq!(icon(100.0), BuiltinIcon::BatteryFull);
}
#[test]
fn charging_shows_the_bolt_and_full_shows_a_full_battery() {
assert_eq!(
default_icon(Battery::new(BatteryState::Charging, 5.0)),
BuiltinIcon::BatteryCharging
);
assert_eq!(
default_icon(Battery::new(BatteryState::Full, 100.0)),
BuiltinIcon::BatteryFull
);
}
#[test]
fn template_marks_the_icon_slot_before_the_label() {
let mut widget = BatteryWidget::new(Bounds::new(0, 0, 320, 32));
assert_eq!(widget.template(), "");
widget.update(&battery(BatteryState::Discharging, 85.0));
assert_eq!(widget.template(), "{icon} 85% discharging");
}
#[test]
fn configured_format_can_keep_percentage_and_drop_state_text() {
let mut widget = BatteryWidget::new(Bounds::new(0, 0, 320, 32))
.with_format(Some("{icon} {percent}%".to_string()));
widget.update(&battery(BatteryState::Charging, 81.0));
assert_eq!(widget.template(), "{icon} 81%");
}
#[test]
fn battery_format_rejects_unknown_or_unbalanced_placeholders() {
assert!(validate_battery_format("{icon} {percent}% {state}").is_ok());
assert!(validate_battery_format("{watts}").is_err());
assert!(validate_battery_format("{percent").is_err());
assert!(validate_battery_format("percent}").is_err());
}
#[test]
fn charging_battery_uses_its_charging_colors() {
let charging = StateColors {
background: Some((0x2D, 0xF1, 0x85, 0x20)),
foreground: (0x5F, 0xF5, 0xA0, 0xFF),
border: Some((0x2D, 0xF1, 0x85, 0xFF)),
};
let style = WidgetStyle {
charging,
..WidgetStyle::default()
};
let widget = BatteryWidget::new(Bounds::new(0, 0, 320, 32)).with_style(style);
assert_eq!(
widget.state_colors(Battery::new(BatteryState::Charging, 81.0)),
charging
);
}
#[test]
fn a_low_discharging_battery_uses_the_warn_colors() {
let widget = BatteryWidget::new(Bounds::new(0, 0, 320, 32));
let style = WidgetStyle::default();
assert_eq!(
widget.state_colors(Battery::new(BatteryState::Discharging, 15.0)),
style.warn
);
}
#[test]
fn a_healthy_discharging_battery_recolors_by_charge_tier() {
let widget = BatteryWidget::new(Bounds::new(0, 0, 320, 32));
let base = WidgetStyle::default().base_colors();
let fg = |percent| {
widget
.state_colors(Battery::new(BatteryState::Discharging, percent))
.foreground
};
assert_eq!(fg(30.0), LEVEL_LOW);
assert_eq!(fg(55.0), LEVEL_MID);
assert_eq!(fg(90.0), LEVEL_HIGH);
let colors = widget.state_colors(Battery::new(BatteryState::Discharging, 55.0));
assert_eq!(colors.background, base.background);
assert_eq!(colors.border, base.border);
}
#[test]
fn a_battery_on_ac_uses_the_charging_colors() {
let widget = BatteryWidget::new(Bounds::new(0, 0, 320, 32));
assert_eq!(
widget.state_colors(Battery::new(BatteryState::Charging, 5.0)),
WidgetStyle::default().charging
);
}
#[test]
fn an_absent_battery_measures_zero_a_present_one_reserves_a_slot() {
let mut ctx = RenderContext::new(320, 32);
let mut widget = BatteryWidget::new(Bounds::new(0, 0, 320, 32));
assert_eq!(widget.measure(&mut ctx, 32), 0);
widget.update(&battery(BatteryState::Discharging, 85.0));
assert!(widget.measure(&mut ctx, 32) > 0);
}
}