use crate::icon::BuiltinIcon;
use crate::render::{Bounds, RenderContext};
use super::{
IconSetting, LaunchSpec, Msg, ResolvedIcon, Tooltip, Widget, WidgetStyle, draw_icon_content,
expand, measure_icon_content,
};
const DEFAULT_FORMAT: &str = "{icon}{percent}%";
pub const VOLUME_PLACEHOLDERS: &[&str] = &["icon", "percent", "device"];
pub fn validate_volume_format(format: &str) -> Result<(), String> {
super::validate_placeholders(format, VOLUME_PLACEHOLDERS)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeviceKind {
Headphones,
Headset,
Speakers,
Monitor,
Phone,
Tv,
Other,
}
impl DeviceKind {
pub fn label(self) -> &'static str {
match self {
DeviceKind::Headphones => "Headphones",
DeviceKind::Headset => "Headset",
DeviceKind::Speakers => "Speakers",
DeviceKind::Monitor => "Monitor",
DeviceKind::Phone => "Phone",
DeviceKind::Tv => "TV",
DeviceKind::Other => "Other",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Volume {
level: u8,
muted: bool,
device: DeviceKind,
}
impl Volume {
pub fn new(level: f32, muted: bool, device: DeviceKind) -> Self {
Self {
level: normalize_level(level),
muted,
device,
}
}
pub fn level(self) -> u8 {
self.level
}
pub fn muted(self) -> bool {
self.muted
}
pub fn device(self) -> DeviceKind {
self.device
}
pub fn label(self) -> String {
if self.muted {
"Muted".to_string()
} else {
format!("{}%", self.level)
}
}
}
fn normalize_level(value: f32) -> u8 {
if value.is_nan() || value <= 0.0 {
0
} else if value >= 1.0 {
100
} else {
(value * 100.0).round() as u8
}
}
pub struct VolumeWidget {
bounds: Bounds,
state: Option<Volume>,
style: WidgetStyle,
on_click: Option<LaunchSpec>,
format: String,
format_icons: Option<Vec<String>>,
muted_icon: Option<String>,
tooltip: bool,
tooltip_format: Option<String>,
}
impl VolumeWidget {
pub fn new(bounds: Bounds) -> Self {
Self {
bounds,
state: None,
style: WidgetStyle::default(),
on_click: None,
format: DEFAULT_FORMAT.to_string(),
format_icons: None,
muted_icon: None,
tooltip: true,
tooltip_format: None,
}
}
pub fn with_format(mut self, format: Option<String>) -> Self {
if let Some(format) = format {
self.format = format;
}
self
}
pub fn with_format_icons(mut self, icons: Option<Vec<String>>) -> Self {
self.format_icons = icons.filter(|icons| !icons.is_empty());
self
}
pub fn with_muted_icon(mut self, icon: Option<String>) -> Self {
self.muted_icon = icon;
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
}
pub fn with_style(mut self, style: WidgetStyle) -> Self {
self.style = style;
self
}
pub fn with_on_click(mut self, path: Option<LaunchSpec>) -> Self {
self.on_click = path;
self
}
pub fn label(&self) -> String {
self.state.map(Volume::label).unwrap_or_default()
}
fn template(&self) -> String {
match &self.state {
Some(volume) if volume.muted() => "{icon}".to_string(),
Some(volume) => self.expand(&self.format, *volume),
None => String::new(),
}
}
fn expand(&self, format: &str, volume: Volume) -> String {
expand(
format,
&[
("percent", &volume.level().to_string()),
("device", volume.device().label()),
],
)
}
pub fn tooltip_text(&self) -> Option<String> {
if !self.tooltip {
return None;
}
let volume = self.state?;
Some(match &self.tooltip_format {
Some(format) => {
let icon = match self.icon() {
ResolvedIcon::Text(icon) => icon,
_ => String::new(),
};
self.expand(format, volume).replace("{icon}", &icon)
}
None => {
let mut text = format!(
"Volume: {}%\nDevice: {}",
volume.level(),
volume.device().label()
);
if volume.muted() {
text.push_str("\nMuted");
}
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 icon(&self) -> ResolvedIcon {
if let (IconSetting::Default, Some(volume)) = (&self.style.icon, &self.state) {
if volume.muted() {
if let Some(icon) = &self.muted_icon {
return ResolvedIcon::Text(icon.clone());
}
} else if let Some(icons) = &self.format_icons {
let index = (usize::from(volume.level()) * icons.len()) / 101;
return ResolvedIcon::Text(icons[index].clone());
}
}
let default = match &self.state {
Some(volume) if volume.muted() || volume.level() == 0 => BuiltinIcon::VolumeMuted,
Some(volume) => match volume.level() {
1..=33 => BuiltinIcon::VolumeLow,
34..=66 => BuiltinIcon::VolumeMedium,
_ => BuiltinIcon::VolumeHigh,
},
None => return ResolvedIcon::None,
};
self.style.resolve_icon(default)
}
}
impl Widget for VolumeWidget {
fn update(&mut self, msg: &Msg) -> bool {
match msg {
Msg::Volume(next) => {
if self.state == *next {
return false;
}
self.state = *next;
true
}
_ => false,
}
}
#[hotpath::measure(impl_type = "VolumeWidget")]
fn draw(&self, ctx: &mut RenderContext) {
draw_icon_content(
ctx,
&self.style,
self.bounds,
&self.icon(),
&self.template(),
self.style.base_colors(),
);
}
#[hotpath::measure(impl_type = "VolumeWidget")]
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;
}
fn on_click(&self, px: u32, py: u32, button: super::ClickButton) -> Option<super::Command> {
if button != super::ClickButton::Left {
return None;
}
let path = self.on_click.as_ref()?;
if !self.contains(px, py) {
return None;
}
Some(super::Command::RunProgram(path.clone()))
}
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 crate::widget::ClickButton;
use chrono::{Local, TimeZone};
fn vol(level: f32, muted: bool, device: DeviceKind) -> Msg {
Msg::Volume(Some(Volume::new(level, muted, device)))
}
#[test]
fn new_clamps_a_negative_level_to_zero() {
let v = Volume::new(-0.5, false, DeviceKind::Speakers);
assert_eq!(v.level(), 0);
}
#[test]
fn new_clamps_an_above_one_level_to_one_hundred() {
let v = Volume::new(2.5, false, DeviceKind::Speakers);
assert_eq!(v.level(), 100);
}
#[test]
fn new_rounds_a_sub_percent_level_to_the_nearest_whole_percent() {
let v = Volume::new(0.424, false, DeviceKind::Speakers);
assert_eq!(v.level(), 42);
let v = Volume::new(0.426, false, DeviceKind::Speakers);
assert_eq!(v.level(), 43);
}
#[test]
fn new_treats_nan_and_zero_levels_as_zero() {
assert_eq!(Volume::new(f32::NAN, false, DeviceKind::Other).level(), 0);
assert_eq!(Volume::new(0.0, false, DeviceKind::Other).level(), 0);
}
#[test]
fn label_shows_the_bare_percent_when_unmuted() {
assert_eq!(
Volume::new(0.42, false, DeviceKind::Speakers).label(),
"42%"
);
}
#[test]
fn label_shows_mute_when_muted() {
assert_eq!(
Volume::new(0.42, true, DeviceKind::Speakers).label(),
"Muted"
);
assert_eq!(Volume::new(0.0, true, DeviceKind::Other).label(), "Muted");
}
#[test]
fn label_does_not_include_the_device_name() {
for device in [
DeviceKind::Headphones,
DeviceKind::Headset,
DeviceKind::Speakers,
DeviceKind::Monitor,
DeviceKind::Phone,
DeviceKind::Tv,
DeviceKind::Other,
] {
assert_eq!(Volume::new(0.5, false, device).label(), "50%");
}
}
#[test]
fn first_reading_changes_state_and_sets_label() {
let mut widget = VolumeWidget::new(Bounds::new(0, 0, 320, 32));
assert_eq!(widget.label(), "");
assert!(widget.update(&vol(0.42, false, DeviceKind::Speakers)));
assert_eq!(widget.label(), "42%");
}
#[test]
fn identical_reading_is_not_a_visible_change() {
let mut widget = VolumeWidget::new(Bounds::new(0, 0, 320, 32));
assert!(widget.update(&vol(0.42, false, DeviceKind::Speakers)));
assert!(!widget.update(&vol(0.424, false, DeviceKind::Speakers)));
assert_eq!(widget.label(), "42%");
}
#[test]
fn a_new_level_is_a_visible_change() {
let mut widget = VolumeWidget::new(Bounds::new(0, 0, 320, 32));
assert!(widget.update(&vol(0.42, false, DeviceKind::Speakers)));
assert!(widget.update(&vol(0.65, false, DeviceKind::Speakers)));
assert_eq!(widget.label(), "65%");
}
#[test]
fn a_toggle_to_mute_is_a_visible_change() {
let mut widget = VolumeWidget::new(Bounds::new(0, 0, 320, 32));
assert!(widget.update(&vol(0.42, false, DeviceKind::Speakers)));
assert!(widget.update(&vol(0.42, true, DeviceKind::Speakers)));
assert_eq!(widget.label(), "Muted");
}
#[test]
fn a_toggle_back_from_mute_is_a_visible_change() {
let mut widget = VolumeWidget::new(Bounds::new(0, 0, 320, 32));
assert!(widget.update(&vol(0.42, true, DeviceKind::Speakers)));
assert!(widget.update(&vol(0.42, false, DeviceKind::Speakers)));
assert_eq!(widget.label(), "42%");
}
#[test]
fn a_new_device_kind_is_a_visible_change() {
let mut widget = VolumeWidget::new(Bounds::new(0, 0, 320, 32));
assert!(widget.update(&vol(0.5, false, DeviceKind::Speakers)));
assert!(widget.update(&vol(0.5, false, DeviceKind::Headphones)));
}
#[test]
fn a_volume_going_absent_is_a_visible_change_then_blank() {
let mut widget = VolumeWidget::new(Bounds::new(0, 0, 320, 32));
assert!(widget.update(&vol(0.42, false, DeviceKind::Speakers)));
assert!(widget.update(&Msg::Volume(None)));
assert_eq!(widget.label(), "");
}
#[test]
fn absent_reading_before_any_volume_is_not_a_change() {
let mut widget = VolumeWidget::new(Bounds::new(0, 0, 320, 32));
assert!(!widget.update(&Msg::Volume(None)));
assert_eq!(widget.label(), "");
}
#[test]
fn unrelated_message_is_ignored() {
let mut widget = VolumeWidget::new(Bounds::new(0, 0, 320, 32));
widget.update(&vol(0.42, false, DeviceKind::Speakers));
let tick = Msg::Tick(Local.with_ymd_and_hms(2026, 6, 27, 8, 0, 0).unwrap());
assert!(!widget.update(&tick));
assert_eq!(widget.label(), "42%");
}
#[test]
fn set_bounds_repositions_the_widget() {
let mut widget = VolumeWidget::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 icon_reflects_the_level_bucket_and_mute() {
let mut widget = VolumeWidget::new(Bounds::new(0, 0, 320, 32));
assert_eq!(widget.icon(), ResolvedIcon::None);
assert_eq!(widget.template(), "");
widget.update(&vol(0.10, false, DeviceKind::Speakers));
assert_eq!(widget.icon(), ResolvedIcon::Builtin(BuiltinIcon::VolumeLow));
widget.update(&vol(0.50, false, DeviceKind::Speakers));
assert_eq!(
widget.icon(),
ResolvedIcon::Builtin(BuiltinIcon::VolumeMedium)
);
widget.update(&vol(0.90, false, DeviceKind::Speakers));
assert_eq!(
widget.icon(),
ResolvedIcon::Builtin(BuiltinIcon::VolumeHigh)
);
widget.update(&vol(0.90, true, DeviceKind::Speakers));
assert_eq!(
widget.icon(),
ResolvedIcon::Builtin(BuiltinIcon::VolumeMuted)
);
widget.update(&vol(0.0, false, DeviceKind::Speakers));
assert_eq!(
widget.icon(),
ResolvedIcon::Builtin(BuiltinIcon::VolumeMuted)
);
}
#[test]
fn template_pairs_the_icon_slot_with_the_label() {
let mut widget = VolumeWidget::new(Bounds::new(0, 0, 320, 32));
widget.update(&vol(0.42, false, DeviceKind::Speakers));
assert_eq!(widget.template(), "{icon}42%");
widget.update(&vol(0.42, true, DeviceKind::Speakers));
assert_eq!(widget.template(), "{icon}");
}
#[test]
fn a_custom_icon_setting_overrides_the_built_in() {
let style = WidgetStyle {
icon: crate::widget::IconSetting::Custom("\u{f013}".to_string()),
..WidgetStyle::default()
};
let mut widget = VolumeWidget::new(Bounds::new(0, 0, 320, 32)).with_style(style);
widget.update(&vol(0.5, false, DeviceKind::Speakers));
assert_eq!(widget.icon(), ResolvedIcon::Text("\u{f013}".into()));
}
#[test]
fn icon_none_setting_opts_out_but_keeps_the_label() {
let style = WidgetStyle {
icon: crate::widget::IconSetting::None,
..WidgetStyle::default()
};
let mut widget = VolumeWidget::new(Bounds::new(0, 0, 320, 32)).with_style(style);
widget.update(&vol(0.5, false, DeviceKind::Speakers));
assert_eq!(widget.icon(), ResolvedIcon::None);
assert_eq!(widget.template(), "{icon}50%");
}
#[test]
fn no_volume_measures_zero_a_sampled_one_reserves_a_slot() {
let mut ctx = RenderContext::new(320, 32);
let mut widget = VolumeWidget::new(Bounds::new(0, 0, 320, 32));
assert_eq!(widget.measure(&mut ctx, 32), 0);
widget.update(&vol(0.5, false, DeviceKind::Speakers));
assert!(widget.measure(&mut ctx, 32) > 0);
}
#[test]
fn custom_format_expands_percent_and_device() {
let mut widget = VolumeWidget::new(Bounds::new(0, 0, 320, 32))
.with_format(Some("{icon} {percent}% {device}".into()));
widget.update(&vol(0.42, false, DeviceKind::Headphones));
assert_eq!(widget.template(), "{icon} 42% Headphones");
}
#[test]
fn format_icons_ramp_picks_by_level_and_muted_icon_wins() {
let mut widget = VolumeWidget::new(Bounds::new(0, 0, 320, 32))
.with_format_icons(Some(vec!["a".into(), "b".into(), "c".into()]))
.with_muted_icon(Some("m".into()));
widget.update(&vol(0.10, false, DeviceKind::Speakers));
assert_eq!(widget.icon(), ResolvedIcon::Text("a".into()));
widget.update(&vol(0.50, false, DeviceKind::Speakers));
assert_eq!(widget.icon(), ResolvedIcon::Text("b".into()));
widget.update(&vol(1.0, false, DeviceKind::Speakers));
assert_eq!(widget.icon(), ResolvedIcon::Text("c".into()));
widget.update(&vol(1.0, true, DeviceKind::Speakers));
assert_eq!(widget.icon(), ResolvedIcon::Text("m".into()));
}
#[test]
fn a_ramp_without_a_muted_icon_keeps_the_built_in_muted_art() {
let mut widget =
VolumeWidget::new(Bounds::new(0, 0, 320, 32)).with_format_icons(Some(vec!["a".into()]));
widget.update(&vol(0.5, true, DeviceKind::Speakers));
assert_eq!(
widget.icon(),
ResolvedIcon::Builtin(BuiltinIcon::VolumeMuted)
);
}
#[test]
fn default_tooltip_lists_level_device_and_mute() {
let mut widget = VolumeWidget::new(Bounds::new(0, 0, 320, 32));
assert_eq!(widget.tooltip_text(), None);
widget.update(&vol(0.42, false, DeviceKind::Speakers));
assert_eq!(
widget.tooltip_text().as_deref(),
Some("Volume: 42%\nDevice: Speakers")
);
widget.update(&vol(0.42, true, DeviceKind::Headset));
assert_eq!(
widget.tooltip_text().as_deref(),
Some("Volume: 42%\nDevice: Headset\nMuted")
);
}
#[test]
fn tooltip_format_and_opt_out() {
let mut widget = VolumeWidget::new(Bounds::new(0, 0, 320, 32))
.with_tooltip_format(Some("{device} at {percent}%".into()));
widget.update(&vol(0.42, false, DeviceKind::Tv));
assert_eq!(widget.tooltip_text().as_deref(), Some("TV at 42%"));
assert_eq!(
widget.tooltip_at(10, 10).map(|tip| tip.text),
Some("TV at 42%".to_string())
);
assert_eq!(widget.tooltip_at(400, 10), None);
let mut widget = VolumeWidget::new(Bounds::new(0, 0, 320, 32)).with_tooltip(Some(false));
widget.update(&vol(0.42, false, DeviceKind::Tv));
assert_eq!(widget.tooltip_at(10, 10), None);
}
#[test]
fn without_on_click_a_click_is_a_no_op() {
let widget = VolumeWidget::new(Bounds::new(0, 0, 200, 32));
assert_eq!(widget.on_click(10, 10, ClickButton::Left), None);
}
#[test]
fn with_on_click_a_click_inside_bounds_runs_the_program() {
let widget = VolumeWidget::new(Bounds::new(0, 0, 200, 32))
.with_on_click(Some(LaunchSpec::program_only("/usr/bin/pavucontrol")));
assert_eq!(
widget.on_click(10, 10, ClickButton::Left),
Some(super::super::Command::RunProgram(LaunchSpec::program_only(
"/usr/bin/pavucontrol"
)))
);
}
#[test]
fn on_click_outside_bounds_is_ignored_even_when_configured() {
let widget = VolumeWidget::new(Bounds::new(10, 0, 200, 32))
.with_on_click(Some(LaunchSpec::program_only("/usr/bin/pavucontrol")));
assert_eq!(widget.on_click(0, 10, ClickButton::Left), None);
assert_eq!(widget.on_click(210, 10, ClickButton::Left), None);
assert_eq!(widget.on_click(50, 32, ClickButton::Left), None);
}
}