use crate::render::{Bounds, RenderContext};
use super::{
Msg, StateColors, Widget, WidgetStyle, draw_text_pill, glyph_label, measure_text_pill,
};
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(())
}
const BATTERY_EMPTY: &str = "\u{f244}"; const BATTERY_QUARTER: &str = "\u{f243}"; const BATTERY_HALF: &str = "\u{f242}"; const BATTERY_THREE_QUARTERS: &str = "\u{f241}"; const BATTERY_FULL: &str = "\u{f240}"; const BATTERY_CHARGING: &str = "\u{f0e7}";
#[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())
}
}
fn default_glyph(battery: Battery) -> &'static str {
match battery.state() {
BatteryState::Charging | BatteryState::Full => BATTERY_CHARGING,
_ => match battery.percent() {
0..=19 => BATTERY_EMPTY,
20..=39 => BATTERY_QUARTER,
40..=59 => BATTERY_HALF,
60..=79 => BATTERY_THREE_QUARTERS,
_ => BATTERY_FULL,
},
}
}
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 display_text(&self) -> String {
match self.state {
Some(battery) => {
let icon = self.style.glyph(default_glyph(battery));
match &self.format {
Some(format) => format
.replace("{icon}", icon)
.replace("{percent}", &battery.percent().to_string())
.replace("{state}", battery.state().label()),
None => glyph_label(icon, &battery.label()),
}
}
None => String::new(),
}
}
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.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_text_pill(
ctx,
&self.style,
self.bounds,
&self.display_text(),
self.state_colors(battery),
);
}
}
fn measure(&self, ctx: &mut RenderContext, _height: u32) -> u32 {
measure_text_pill(ctx, &self.style, &self.display_text())
}
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_glyph_ramps_by_quintile_while_discharging() {
let glyph = |percent| default_glyph(Battery::new(BatteryState::Discharging, percent));
assert_eq!(glyph(0.0), BATTERY_EMPTY);
assert_eq!(glyph(19.0), BATTERY_EMPTY);
assert_eq!(glyph(20.0), BATTERY_QUARTER);
assert_eq!(glyph(39.0), BATTERY_QUARTER);
assert_eq!(glyph(40.0), BATTERY_HALF);
assert_eq!(glyph(59.0), BATTERY_HALF);
assert_eq!(glyph(60.0), BATTERY_THREE_QUARTERS);
assert_eq!(glyph(79.0), BATTERY_THREE_QUARTERS);
assert_eq!(glyph(80.0), BATTERY_FULL);
assert_eq!(glyph(100.0), BATTERY_FULL);
}
#[test]
fn charging_or_full_shows_the_bolt_glyph() {
assert_eq!(
default_glyph(Battery::new(BatteryState::Charging, 5.0)),
BATTERY_CHARGING
);
assert_eq!(
default_glyph(Battery::new(BatteryState::Full, 100.0)),
BATTERY_CHARGING
);
}
#[test]
fn display_text_prefixes_the_state_glyph() {
let mut widget = BatteryWidget::new(Bounds::new(0, 0, 320, 32));
assert_eq!(widget.display_text(), "");
widget.update(&battery(BatteryState::Discharging, 85.0));
assert_eq!(
widget.display_text(),
format!("{BATTERY_FULL} 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.display_text(), format!("{BATTERY_CHARGING} 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
);
assert_eq!(
widget.state_colors(Battery::new(BatteryState::Discharging, 50.0)),
style.base_colors()
);
}
#[test]
fn a_low_battery_on_ac_keeps_the_base_colors() {
let widget = BatteryWidget::new(Bounds::new(0, 0, 320, 32));
assert_eq!(
widget.state_colors(Battery::new(BatteryState::Charging, 5.0)),
WidgetStyle::default().base_colors()
);
}
#[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);
}
}