use crate::icon::BuiltinIcon;
use crate::render::{Bounds, RenderContext};
use super::{
Msg, ResolvedIcon, Widget, WidgetStyle, draw_builtin_icons, draw_icon_content,
measure_icon_content,
};
const SYSTEM_ICONS: [BuiltinIcon; 2] = [BuiltinIcon::System, BuiltinIcon::Memory];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SystemStats {
cpu: u8,
mem: u8,
}
impl SystemStats {
pub fn new(cpu: f64, mem: f64) -> Self {
Self {
cpu: normalize_percent(cpu),
mem: normalize_percent(mem),
}
}
pub fn cpu(self) -> u8 {
self.cpu
}
pub fn mem(self) -> u8 {
self.mem
}
pub fn label(self) -> String {
format!("CPU {}% MEM {}%", self.cpu, self.mem)
}
}
fn normalize_percent(value: f64) -> u8 {
if value.is_nan() {
0
} else {
value.clamp(0.0, 100.0).round() as u8
}
}
pub struct SystemWidget {
bounds: Bounds,
state: Option<SystemStats>,
style: WidgetStyle,
}
impl SystemWidget {
pub fn new(bounds: Bounds) -> Self {
Self {
bounds,
state: None,
style: WidgetStyle::default(),
}
}
pub fn with_style(mut self, style: WidgetStyle) -> Self {
self.style = style;
self
}
pub fn label(&self) -> String {
self.state.map(SystemStats::label).unwrap_or_default()
}
fn template(&self) -> String {
let Some(stats) = self.state else {
return String::new();
};
match self.icon() {
ResolvedIcon::Builtin(_) => {
format!("{{icon}} {}% {{icon}} {}%", stats.cpu(), stats.mem())
}
_ => format!("{{icon}} {}", stats.label()),
}
}
fn icon(&self) -> ResolvedIcon {
match self.state {
Some(_) => self.style.resolve_icon(BuiltinIcon::System),
None => ResolvedIcon::None,
}
}
}
impl Widget for SystemWidget {
fn update(&mut self, msg: &Msg) -> bool {
match msg {
Msg::System(next) => {
if self.state == Some(*next) {
return false;
}
self.state = Some(*next);
true
}
_ => false,
}
}
fn draw(&self, ctx: &mut RenderContext) {
let template = self.template();
let colors = self.style.base_colors();
match self.icon() {
ResolvedIcon::Builtin(_) => {
draw_builtin_icons(
ctx,
&self.style,
self.bounds,
&SYSTEM_ICONS,
&template,
colors,
);
}
other => draw_icon_content(ctx, &self.style, self.bounds, &other, &template, colors),
}
}
fn measure(&self, ctx: &mut RenderContext, _height: u32) -> u32 {
measure_icon_content(ctx, &self.style, &self.icon(), &self.template())
}
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 stats(cpu: f64, mem: f64) -> Msg {
Msg::System(SystemStats::new(cpu, mem))
}
#[test]
fn new_rounds_each_reading_to_whole_percent() {
let s = SystemStats::new(12.4, 46.6);
assert_eq!(s.cpu(), 12);
assert_eq!(s.mem(), 47);
}
#[test]
fn new_clamps_out_of_range_readings() {
let s = SystemStats::new(137.0, -5.0);
assert_eq!(s.cpu(), 100);
assert_eq!(s.mem(), 0);
}
#[test]
fn new_treats_nan_readings_as_zero() {
let s = SystemStats::new(f64::NAN, f64::NAN);
assert_eq!(s.cpu(), 0);
assert_eq!(s.mem(), 0);
}
#[test]
fn label_joins_cpu_and_memory() {
assert_eq!(SystemStats::new(12.0, 47.0).label(), "CPU 12% MEM 47%");
assert_eq!(SystemStats::new(0.0, 100.0).label(), "CPU 0% MEM 100%");
}
#[test]
fn first_sample_changes_state_and_sets_label() {
let mut widget = SystemWidget::new(Bounds::new(0, 0, 320, 32));
assert_eq!(widget.label(), "");
assert!(widget.update(&stats(12.0, 47.0)));
assert_eq!(widget.label(), "CPU 12% MEM 47%");
}
#[test]
fn identical_sample_is_not_a_visible_change() {
let mut widget = SystemWidget::new(Bounds::new(0, 0, 320, 32));
assert!(widget.update(&stats(12.0, 47.0)));
assert!(!widget.update(&stats(12.2, 47.3)));
assert_eq!(widget.label(), "CPU 12% MEM 47%");
}
#[test]
fn a_new_cpu_reading_is_a_visible_change() {
let mut widget = SystemWidget::new(Bounds::new(0, 0, 320, 32));
assert!(widget.update(&stats(12.0, 47.0)));
assert!(widget.update(&stats(30.0, 47.0)));
assert_eq!(widget.label(), "CPU 30% MEM 47%");
}
#[test]
fn a_new_memory_reading_is_a_visible_change() {
let mut widget = SystemWidget::new(Bounds::new(0, 0, 320, 32));
assert!(widget.update(&stats(12.0, 47.0)));
assert!(widget.update(&stats(12.0, 63.0)));
assert_eq!(widget.label(), "CPU 12% MEM 63%");
}
#[test]
fn unrelated_message_is_ignored() {
let mut widget = SystemWidget::new(Bounds::new(0, 0, 320, 32));
widget.update(&stats(12.0, 47.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(), "CPU 12% MEM 47%");
}
#[test]
fn set_bounds_repositions_the_widget() {
let mut widget = SystemWidget::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 template_gives_each_built_in_icon_its_own_percent() {
let mut widget = SystemWidget::new(Bounds::new(0, 0, 320, 32));
assert_eq!(widget.icon(), ResolvedIcon::None);
assert_eq!(widget.template(), "");
widget.update(&stats(12.0, 47.0));
assert_eq!(widget.template(), "{icon} 12% {icon} 47%");
assert_eq!(widget.icon(), ResolvedIcon::Builtin(BuiltinIcon::System));
}
#[test]
fn a_custom_icon_setting_overrides_the_built_in() {
let style = WidgetStyle {
icon: crate::widget::IconSetting::Custom("\u{f2db}".to_string()),
..WidgetStyle::default()
};
let mut widget = SystemWidget::new(Bounds::new(0, 0, 320, 32)).with_style(style);
widget.update(&stats(12.0, 47.0));
assert_eq!(widget.icon(), ResolvedIcon::Text("\u{f2db}".into()));
}
#[test]
fn icon_none_setting_opts_out_but_keeps_the_readout() {
let style = WidgetStyle {
icon: crate::widget::IconSetting::None,
..WidgetStyle::default()
};
let mut widget = SystemWidget::new(Bounds::new(0, 0, 320, 32)).with_style(style);
widget.update(&stats(12.0, 47.0));
assert_eq!(widget.icon(), ResolvedIcon::None);
assert_eq!(widget.template(), "{icon} CPU 12% MEM 47%");
}
#[test]
fn no_sample_measures_zero_a_sampled_one_reserves_a_slot() {
let mut ctx = RenderContext::new(320, 32);
let mut widget = SystemWidget::new(Bounds::new(0, 0, 320, 32));
assert_eq!(widget.measure(&mut ctx, 32), 0);
widget.update(&stats(12.0, 47.0));
assert!(widget.measure(&mut ctx, 32) > 0);
}
}