use crate::icon::BuiltinIcon;
use crate::render::{Bounds, RenderContext};
use super::{
Msg, ResolvedIcon, StateColors, Tooltip, Widget, WidgetStyle, draw_icon_content, expand,
measure_icon_content,
};
pub const BATTERY_PLACEHOLDERS: &[&str] = &["icon", "percent", "state", "time"];
pub fn validate_battery_format(format: &str) -> Result<(), String> {
super::validate_placeholders(format, BATTERY_PLACEHOLDERS)
}
#[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,
minutes: Option<u32>,
}
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,
minutes: None,
}
}
pub fn with_time(mut self, seconds: i64) -> Self {
self.minutes = u32::try_from((seconds + 30) / 60)
.ok()
.filter(|&minutes| seconds > 0 && minutes > 0);
self
}
pub fn minutes(self) -> Option<u32> {
self.minutes
}
pub fn time_label(self) -> Option<String> {
self.minutes.map(|minutes| match minutes / 60 {
0 => format!("{minutes}m"),
hours => format!("{hours}h {}m", minutes % 60),
})
}
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>,
tooltip: bool,
tooltip_format: Option<String>,
}
impl BatteryWidget {
pub fn new(bounds: Bounds) -> Self {
Self {
bounds,
state: None,
style: WidgetStyle::default(),
format: None,
tooltip: true,
tooltip_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 with_tooltip(mut self, tooltip: Option<bool>) -> Self {
if let Some(tooltip) = tooltip {
self.tooltip = tooltip;
}
self
}
pub fn with_tooltip_format(mut self, format: Option<String>) -> Self {
if format.is_some() {
self.tooltip_format = format;
}
self
}
fn expand(format: &str, battery: Battery) -> String {
expand(
format,
&[
("percent", &battery.percent().to_string()),
("state", battery.state().label()),
("time", &battery.time_label().unwrap_or_default()),
],
)
}
pub fn tooltip_text(&self) -> Option<String> {
if !self.tooltip {
return None;
}
let battery = self.state?;
if let Some(format) = &self.tooltip_format {
let icon = match self.icon(battery) {
ResolvedIcon::Text(icon) => icon,
_ => String::new(),
};
return Some(Self::expand(format, battery).replace("{icon}", &icon));
}
let mut text = format!(
"Battery: {}% ({})",
battery.percent(),
battery.state().label()
);
let direction = match battery.state() {
BatteryState::Discharging => Some("Empty"),
BatteryState::Charging => Some("Full"),
_ => None,
};
if let (Some(direction), Some(time)) = (direction, battery.time_label()) {
text.push_str(&format!("\n{direction} in {time}"));
}
Some(text)
}
fn contains(&self, px: u32, py: u32) -> bool {
let b = self.bounds;
px >= b.x && px < b.x + b.width && py >= b.y && py < b.y + b.height
}
fn visible(&self) -> Option<(String, ResolvedIcon, StateColors)> {
let battery = self.state?;
Some((
self.template(),
self.icon(battery),
self.state_colors(battery),
))
}
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) => Self::expand(format, battery),
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;
}
let before = self.visible();
self.state = *next;
self.visible() != before
}
_ => false,
}
}
#[hotpath::measure(impl_type = "BatteryWidget")]
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),
);
}
}
#[hotpath::measure(impl_type = "BatteryWidget")]
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;
}
fn tooltip_at(&self, px: u32, py: u32) -> Option<Tooltip> {
if !self.contains(px, py) {
return None;
}
Some(Tooltip {
text: self.tooltip_text()?,
bounds: self.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_eq!(validate_battery_format("{icon}{percent}% {time}"), Ok(()));
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
);
}
fn timed(state: BatteryState, percent: f64, seconds: i64) -> Msg {
Msg::Battery(Some(Battery::new(state, percent).with_time(seconds)))
}
#[test]
fn time_is_whole_minutes_and_zero_means_unknown() {
let b = Battery::new(BatteryState::Discharging, 50.0).with_time(2 * 3600 + 14 * 60 + 20);
assert_eq!(b.minutes(), Some(134));
assert_eq!(b.time_label().as_deref(), Some("2h 14m"));
let b = Battery::new(BatteryState::Discharging, 50.0).with_time(45 * 60);
assert_eq!(b.time_label().as_deref(), Some("45m"));
assert_eq!(
Battery::new(BatteryState::Discharging, 50.0)
.with_time(0)
.minutes(),
None
);
assert_eq!(
Battery::new(BatteryState::Discharging, 50.0)
.with_time(-5)
.minutes(),
None
);
}
#[test]
fn default_tooltip_shows_state_percent_and_time_remaining() {
let mut widget = BatteryWidget::new(Bounds::new(0, 0, 320, 32));
assert_eq!(widget.tooltip_text(), None);
widget.update(&timed(BatteryState::Discharging, 85.0, 2 * 3600 + 14 * 60));
assert_eq!(
widget.tooltip_at(10, 10).map(|tip| tip.text).as_deref(),
Some("Battery: 85% (discharging)\nEmpty in 2h 14m")
);
widget.update(&timed(BatteryState::Charging, 40.0, 30 * 60));
assert_eq!(
widget.tooltip_text().as_deref(),
Some("Battery: 40% (charging)\nFull in 30m")
);
widget.update(&battery(BatteryState::Full, 100.0));
assert_eq!(
widget.tooltip_text().as_deref(),
Some("Battery: 100% (full)")
);
assert_eq!(widget.tooltip_at(400, 10), None);
}
#[test]
fn a_time_change_updates_the_tooltip_without_a_redraw() {
let mut widget = BatteryWidget::new(Bounds::new(0, 0, 320, 32));
assert!(widget.update(&timed(BatteryState::Discharging, 85.0, 3600)));
assert!(!widget.update(&timed(BatteryState::Discharging, 85.0, 3000)));
assert_eq!(
widget.tooltip_text().as_deref(),
Some("Battery: 85% (discharging)\nEmpty in 50m")
);
let mut widget = BatteryWidget::new(Bounds::new(0, 0, 320, 32))
.with_format(Some("{icon}{percent}% {time}".into()));
widget.update(&timed(BatteryState::Discharging, 85.0, 3600));
assert_eq!(widget.template(), "{icon}85% 1h 0m");
assert!(widget.update(&timed(BatteryState::Discharging, 85.0, 3000)));
}
#[test]
fn tooltip_format_and_opt_out() {
let mut widget = BatteryWidget::new(Bounds::new(0, 0, 320, 32))
.with_tooltip_format(Some("{percent} {state} {time}".into()));
widget.update(&timed(BatteryState::Discharging, 85.0, 600));
assert_eq!(widget.tooltip_text().as_deref(), Some("85 discharging 10m"));
let mut widget = BatteryWidget::new(Bounds::new(0, 0, 320, 32)).with_tooltip(Some(false));
widget.update(&battery(BatteryState::Full, 100.0));
assert_eq!(widget.tooltip_at(10, 10), None);
}
#[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);
}
}