use std::collections::BTreeMap;
use crate::render::{Bounds, RenderContext};
use super::{
ClickButton, Command, IconSetting, Msg, Tooltip, Widget, WidgetStyle, draw_text_pill,
measure_text_pill,
};
const DEFAULT_FORMAT: &str = "{icon}";
const DEFAULT_TOOLTIP_FORMAT: &str = "Power profile: {profile}\nDriver: {driver}";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PowerProfile {
name: String,
driver: String,
cpu_driver: String,
platform_driver: String,
}
impl PowerProfile {
pub fn new(
name: impl Into<String>,
driver: impl Into<String>,
cpu_driver: impl Into<String>,
platform_driver: impl Into<String>,
) -> Self {
Self {
name: name.into(),
driver: driver.into(),
cpu_driver: cpu_driver.into(),
platform_driver: platform_driver.into(),
}
}
pub fn name(&self) -> &str {
&self.name
}
pub fn driver(&self) -> &str {
&self.driver
}
pub fn cpu_driver(&self) -> &str {
&self.cpu_driver
}
pub fn platform_driver(&self) -> &str {
&self.platform_driver
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PowerProfilesState {
active: String,
profiles: Vec<PowerProfile>,
}
impl PowerProfilesState {
pub fn new(active: impl Into<String>, profiles: Vec<PowerProfile>) -> Self {
Self {
active: active.into(),
profiles,
}
}
pub fn active_name(&self) -> &str {
&self.active
}
pub fn profiles(&self) -> &[PowerProfile] {
&self.profiles
}
pub fn active(&self) -> Option<&PowerProfile> {
self.profiles
.iter()
.find(|profile| profile.name == self.active)
}
fn rotated(&self, forward: bool) -> Option<&PowerProfile> {
if self.profiles.is_empty() {
return None;
}
let current = self
.profiles
.iter()
.position(|profile| profile.name == self.active)
.unwrap_or(0);
let next = if forward {
(current + 1) % self.profiles.len()
} else {
(current + self.profiles.len() - 1) % self.profiles.len()
};
self.profiles.get(next)
}
}
pub fn validate_power_profiles_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" | "profile" | "driver" | "cpu_driver" | "platform_driver"
) {
return Err(format!(
"contains unsupported placeholder `{{{placeholder}}}`"
));
}
rest = &rest[close + 1..];
}
Ok(())
}
pub struct PowerProfilesWidget {
bounds: Bounds,
state: Option<PowerProfilesState>,
style: WidgetStyle,
format: String,
tooltip_format: String,
tooltip: bool,
format_icons: BTreeMap<String, String>,
}
impl PowerProfilesWidget {
pub fn new(bounds: Bounds) -> Self {
Self {
bounds,
state: None,
style: WidgetStyle::default(),
format: DEFAULT_FORMAT.to_string(),
tooltip_format: DEFAULT_TOOLTIP_FORMAT.to_string(),
tooltip: true,
format_icons: BTreeMap::from([
("default".to_string(), "".to_string()),
("performance".to_string(), "".to_string()),
("balanced".to_string(), "".to_string()),
("power-saver".to_string(), "".to_string()),
]),
}
}
pub fn with_style(mut self, style: WidgetStyle) -> Self {
self.style = style;
self
}
pub fn with_format(mut self, format: Option<String>) -> Self {
if let Some(format) = format {
self.format = format;
}
self
}
pub fn with_tooltip_format(mut self, format: Option<String>) -> Self {
if let Some(format) = format {
self.tooltip_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_format_icons(mut self, icons: Option<BTreeMap<String, String>>) -> Self {
if let Some(icons) = icons {
self.format_icons = icons;
}
self
}
pub fn label(&self) -> String {
self.active()
.map(|profile| self.expand(&self.format, profile))
.unwrap_or_default()
}
pub fn tooltip_text(&self) -> Option<String> {
self.tooltip
.then(|| self.active().map(|p| self.expand(&self.tooltip_format, p)))
.flatten()
}
fn active(&self) -> Option<&PowerProfile> {
self.state.as_ref()?.active()
}
fn icon(&self, profile: &PowerProfile) -> &str {
match &self.style.icon {
IconSetting::None => "",
IconSetting::Custom(icon) => icon,
IconSetting::Default => self
.format_icons
.get(profile.name())
.or_else(|| self.format_icons.get("default"))
.map(String::as_str)
.unwrap_or(""),
}
}
fn expand(&self, format: &str, profile: &PowerProfile) -> String {
format
.replace("{icon}", self.icon(profile))
.replace("{profile}", profile.name())
.replace("{driver}", profile.driver())
.replace("{cpu_driver}", profile.cpu_driver())
.replace("{platform_driver}", profile.platform_driver())
}
fn contains(&self, px: u32, py: u32) -> bool {
px >= self.bounds.x
&& px < self.bounds.x + self.bounds.width
&& py >= self.bounds.y
&& py < self.bounds.y + self.bounds.height
}
}
impl Widget for PowerProfilesWidget {
fn update(&mut self, msg: &Msg) -> bool {
let Msg::PowerProfiles(next) = msg else {
return false;
};
if self.state == *next {
return false;
}
self.state = next.clone();
true
}
fn draw(&self, ctx: &mut RenderContext) {
draw_text_pill(
ctx,
&self.style,
self.bounds,
&self.label(),
self.style.base_colors(),
);
}
fn measure(&self, ctx: &mut RenderContext, _height: u32) -> u32 {
measure_text_pill(ctx, &self.style, &self.label())
}
fn bounds(&self) -> Bounds {
self.bounds
}
fn set_bounds(&mut self, bounds: Bounds) {
self.bounds = bounds;
}
fn on_click(&self, px: u32, py: u32, button: ClickButton) -> Option<Command> {
if !self.contains(px, py) {
return None;
}
let state = self.state.as_ref()?;
let profile = state.rotated(button == ClickButton::Left)?;
Some(Command::SetPowerProfile(profile.name().to_string()))
}
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::*;
fn state(active: &str) -> Msg {
Msg::PowerProfiles(Some(PowerProfilesState::new(
active,
vec![
PowerProfile::new("power-saver", "multiple", "amd_pstate", "placeholder"),
PowerProfile::new("balanced", "multiple", "amd_pstate", "placeholder"),
PowerProfile::new("performance", "amd_pstate", "amd_pstate", "Unavailable"),
],
)))
}
#[test]
fn default_label_uses_active_profile_icon() {
let mut widget = PowerProfilesWidget::new(Bounds::new(0, 0, 64, 32));
widget.update(&state("balanced"));
assert_eq!(widget.label(), "");
}
#[test]
fn formats_label_and_tooltip_fields() {
let mut widget = PowerProfilesWidget::new(Bounds::new(0, 0, 64, 32))
.with_format(Some("{icon} {profile}".into()))
.with_tooltip_format(Some("{driver}/{cpu_driver}/{platform_driver}".into()));
widget.update(&state("balanced"));
assert_eq!(widget.label(), " balanced");
assert_eq!(
widget.tooltip_text().as_deref(),
Some("multiple/amd_pstate/placeholder")
);
}
#[test]
fn clicks_rotate_in_daemon_order() {
let mut widget = PowerProfilesWidget::new(Bounds::new(10, 0, 64, 32));
widget.update(&state("balanced"));
assert_eq!(
widget.on_click(20, 16, ClickButton::Left),
Some(Command::SetPowerProfile("performance".into()))
);
assert_eq!(
widget.on_click(20, 16, ClickButton::Right),
Some(Command::SetPowerProfile("power-saver".into()))
);
}
#[test]
fn unavailable_daemon_hides_and_disables_interaction() {
let mut widget = PowerProfilesWidget::new(Bounds::new(0, 0, 64, 32));
widget.update(&Msg::PowerProfiles(None));
assert_eq!(widget.label(), "");
assert_eq!(widget.on_click(10, 10, ClickButton::Left), None);
assert_eq!(widget.tooltip_at(10, 10), None);
}
#[test]
fn validates_supported_placeholders() {
assert!(validate_power_profiles_format("{icon} {profile} {driver}").is_ok());
assert!(validate_power_profiles_format("{cpu_driver} {platform_driver}").is_ok());
assert!(validate_power_profiles_format("{percent}").is_err());
assert!(validate_power_profiles_format("{profile").is_err());
}
}