use std::path::PathBuf;
use crate::render::{Bounds, RenderContext};
use super::{Msg, Widget, WidgetStyle, draw_text_pill, glyph_label, measure_text_pill};
const BLUETOOTH_GLYPH: &str = "\u{f294}";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BluetoothState {
On,
Off,
Unavailable,
}
impl BluetoothState {
pub fn label(self) -> &'static str {
match self {
BluetoothState::On => "on",
BluetoothState::Off => "off",
BluetoothState::Unavailable => "unavailable",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Bluetooth {
state: BluetoothState,
connected: u16,
}
impl Bluetooth {
pub fn new(state: BluetoothState, count: u32) -> Self {
let connected = if state == BluetoothState::On {
count.min(u32::from(u16::MAX)) as u16
} else {
0
};
Self { state, connected }
}
pub fn state(self) -> BluetoothState {
self.state
}
pub fn connected(self) -> u16 {
self.connected
}
pub fn label(self) -> String {
match (self.state, self.connected) {
(BluetoothState::On, 0) => "on".to_string(),
(BluetoothState::On, 1) => "1 connected".to_string(),
(BluetoothState::On, n) => format!("{n} connected"),
(BluetoothState::Off, _) => "off".to_string(),
(BluetoothState::Unavailable, _) => "unavailable".to_string(),
}
}
}
pub struct BluetoothWidget {
bounds: Bounds,
state: Bluetooth,
style: WidgetStyle,
on_click: Option<PathBuf>,
}
impl BluetoothWidget {
pub fn new(bounds: Bounds) -> Self {
Self {
bounds,
state: Bluetooth::new(BluetoothState::Unavailable, 0),
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.label()
}
fn display_text(&self) -> String {
glyph_label(self.style.glyph(BLUETOOTH_GLYPH), &self.state.label())
}
}
impl Widget for BluetoothWidget {
fn update(&mut self, msg: &Msg) -> bool {
match msg {
Msg::Bluetooth(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 bt(state: BluetoothState, count: u32) -> Msg {
Msg::Bluetooth(Bluetooth::new(state, count))
}
#[test]
fn state_labels_are_unambiguous_words() {
assert_eq!(BluetoothState::On.label(), "on");
assert_eq!(BluetoothState::Off.label(), "off");
assert_eq!(BluetoothState::Unavailable.label(), "unavailable");
}
#[test]
fn new_clamps_an_absurd_count_to_u16_max() {
let bt = Bluetooth::new(BluetoothState::On, u32::from(u16::MAX) + 5);
assert_eq!(bt.connected(), u16::MAX);
}
#[test]
fn new_zeroes_the_count_when_the_adapter_is_off() {
assert_eq!(Bluetooth::new(BluetoothState::Off, 4).connected(), 0);
assert_eq!(
Bluetooth::new(BluetoothState::Unavailable, 7).connected(),
0
);
}
#[test]
fn label_shows_on_for_a_powered_adapter_with_zero_devices() {
assert_eq!(Bluetooth::new(BluetoothState::On, 0).label(), "on");
}
#[test]
fn label_shows_singular_for_one_device() {
assert_eq!(Bluetooth::new(BluetoothState::On, 1).label(), "1 connected");
}
#[test]
fn label_shows_plural_for_many_devices() {
assert_eq!(Bluetooth::new(BluetoothState::On, 2).label(), "2 connected");
assert_eq!(
Bluetooth::new(BluetoothState::On, 99).label(),
"99 connected"
);
}
#[test]
fn label_drops_the_count_when_off_or_unavailable() {
assert_eq!(Bluetooth::new(BluetoothState::Off, 0).label(), "off");
assert_eq!(
Bluetooth::new(BluetoothState::Unavailable, 0).label(),
"unavailable"
);
}
#[test]
fn first_reading_changes_state_and_sets_label() {
let mut widget = BluetoothWidget::new(Bounds::new(0, 0, 320, 32));
assert_eq!(widget.label(), "unavailable");
assert!(widget.update(&bt(BluetoothState::On, 0)));
assert_eq!(widget.label(), "on");
}
#[test]
fn identical_reading_is_not_a_visible_change() {
let mut widget = BluetoothWidget::new(Bounds::new(0, 0, 320, 32));
assert!(widget.update(&bt(BluetoothState::On, 2)));
assert!(!widget.update(&bt(BluetoothState::On, 2)));
assert_eq!(widget.label(), "2 connected");
}
#[test]
fn a_new_count_is_a_visible_change() {
let mut widget = BluetoothWidget::new(Bounds::new(0, 0, 320, 32));
assert!(widget.update(&bt(BluetoothState::On, 1)));
assert!(widget.update(&bt(BluetoothState::On, 2)));
assert_eq!(widget.label(), "2 connected");
}
#[test]
fn a_new_state_is_a_visible_change() {
let mut widget = BluetoothWidget::new(Bounds::new(0, 0, 320, 32));
assert!(widget.update(&bt(BluetoothState::On, 3)));
assert!(widget.update(&bt(BluetoothState::Off, 0)));
assert_eq!(widget.label(), "off");
}
#[test]
fn unavailable_reading_before_any_state_is_not_a_change() {
let mut widget = BluetoothWidget::new(Bounds::new(0, 0, 320, 32));
assert!(!widget.update(&bt(BluetoothState::Unavailable, 0)));
assert_eq!(widget.label(), "unavailable");
}
#[test]
fn unrelated_message_is_ignored() {
let mut widget = BluetoothWidget::new(Bounds::new(0, 0, 320, 32));
widget.update(&bt(BluetoothState::On, 2));
let tick = Msg::Tick(Local.with_ymd_and_hms(2026, 6, 27, 8, 0, 0).unwrap());
assert!(!widget.update(&tick));
assert_eq!(widget.label(), "2 connected");
}
#[test]
fn set_bounds_repositions_the_widget() {
let mut widget = BluetoothWidget::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_prefixes_the_bluetooth_glyph() {
let mut widget = BluetoothWidget::new(Bounds::new(0, 0, 320, 32));
assert_eq!(
widget.display_text(),
format!("{BLUETOOTH_GLYPH} unavailable")
);
widget.update(&bt(BluetoothState::On, 2));
assert_eq!(
widget.display_text(),
format!("{BLUETOOTH_GLYPH} 2 connected")
);
widget.update(&bt(BluetoothState::Off, 0));
assert_eq!(widget.display_text(), format!("{BLUETOOTH_GLYPH} off"));
}
#[test]
fn without_on_click_a_click_is_a_no_op() {
let widget = BluetoothWidget::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 = BluetoothWidget::new(Bounds::new(0, 0, 200, 32))
.with_on_click(Some(PathBuf::from("/usr/bin/blueman-manager")));
assert_eq!(
widget.on_click(10, 10, ClickButton::Left),
Some(super::super::Command::RunProgram(PathBuf::from(
"/usr/bin/blueman-manager"
)))
);
}
#[test]
fn on_click_outside_bounds_is_ignored_even_when_configured() {
let widget = BluetoothWidget::new(Bounds::new(10, 0, 200, 32))
.with_on_click(Some(PathBuf::from("/usr/bin/blueman-manager")));
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);
}
}