use std::path::PathBuf;
use crate::render::{Bounds, RenderContext};
use super::{Msg, Widget, WidgetStyle, draw_text_pill, glyph_label, measure_text_pill};
const MUTED_GLYPH: &str = "\u{f6a9}"; const OTHER_GLYPH: &str = "\u{f028}";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeviceKind {
Headphones,
Headset,
Speakers,
Monitor,
Phone,
Tv,
Other,
}
impl DeviceKind {
pub fn glyph(self) -> &'static str {
match self {
DeviceKind::Headphones => "\u{f025}", DeviceKind::Headset => "\u{f02ce}", DeviceKind::Speakers => "\u{f0577}", DeviceKind::Monitor => "\u{f02d3}", DeviceKind::Phone => "\u{f00e2}", DeviceKind::Tv => "\u{f036b}", DeviceKind::Other => OTHER_GLYPH,
}
}
}
#[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 {
"Mute".to_string()
} else {
format!("Vol {}%", 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<PathBuf>,
}
impl VolumeWidget {
pub fn new(bounds: Bounds) -> Self {
Self {
bounds,
state: None,
style: WidgetStyle::default(),
on_click: None,
}
}
pub fn with_style(mut self, style: WidgetStyle) -> Self {
self.style = style;
self
}
pub fn with_on_click(mut self, path: Option<PathBuf>) -> Self {
self.on_click = path;
self
}
pub fn label(&self) -> String {
self.state.map(Volume::label).unwrap_or_default()
}
fn display_text(&self) -> String {
match &self.state {
Some(volume) => {
let glyph = if volume.muted {
MUTED_GLYPH
} else {
self.style.glyph(volume.device.glyph())
};
glyph_label(glyph, &volume.label())
}
None => String::new(),
}
}
}
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,
}
}
fn draw(&self, ctx: &mut RenderContext) {
draw_text_pill(
ctx,
&self.style,
self.bounds,
&self.display_text(),
self.style.base_colors(),
);
}
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;
}
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()?;
let b = self.bounds;
if px < b.x || px >= b.x + b.width || py < b.y || py >= b.y + b.height {
return None;
}
Some(super::Command::RunProgram(path.clone()))
}
}
#[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 device_kind_glyphs_are_distinct() {
let all = [
DeviceKind::Headphones,
DeviceKind::Headset,
DeviceKind::Speakers,
DeviceKind::Monitor,
DeviceKind::Phone,
DeviceKind::Tv,
DeviceKind::Other,
];
for (i, a) in all.iter().enumerate() {
for b in &all[i + 1..] {
assert_ne!(a.glyph(), b.glyph());
}
}
}
#[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_vol_n_percent_when_unmuted() {
assert_eq!(
Volume::new(0.42, false, DeviceKind::Speakers).label(),
"Vol 42%"
);
}
#[test]
fn label_shows_mute_when_muted() {
assert_eq!(
Volume::new(0.42, true, DeviceKind::Speakers).label(),
"Mute"
);
assert_eq!(Volume::new(0.0, true, DeviceKind::Other).label(), "Mute");
assert_eq!(
Volume::new(1.0, true, DeviceKind::Headphones).label(),
"Mute"
);
}
#[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(), "Vol 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(), "Vol 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(), "Vol 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(), "Vol 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(), "Mute");
}
#[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(), "Vol 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(), "Vol 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 display_text_uses_a_device_driven_glyph() {
let mut widget = VolumeWidget::new(Bounds::new(0, 0, 320, 32));
assert_eq!(widget.display_text(), "");
widget.update(&vol(0.5, false, DeviceKind::Headphones));
assert_eq!(
widget.display_text(),
format!("{} Vol 50%", DeviceKind::Headphones.glyph())
);
widget.update(&vol(0.5, true, DeviceKind::Speakers));
assert_eq!(widget.display_text(), format!("{MUTED_GLYPH} Mute"));
}
#[test]
fn display_text_honors_a_custom_icon_setting() {
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.display_text(), "\u{f013} Vol 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 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(PathBuf::from("/usr/bin/pavucontrol")));
assert_eq!(
widget.on_click(10, 10, ClickButton::Left),
Some(super::super::Command::RunProgram(PathBuf::from(
"/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(PathBuf::from("/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);
}
}