use std::path::PathBuf;
use crate::render::{Bounds, RenderContext};
use super::{
ClickButton, Command, Msg, Tooltip, Widget, WidgetStyle, draw_text_pill, glyph_label,
measure_text_pill,
};
const WIRELESS_GLYPH: &str = "\u{f1eb}"; const WIRED_GLYPH: &str = "\u{f0e8}"; const DISCONNECTED_GLYPH: &str = "\u{f00d}"; const UNKNOWN_GLYPH: &str = "\u{f128}";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NetworkState {
Disconnected,
Wired,
Wireless,
Unknown,
}
impl NetworkState {
pub fn label(self) -> &'static str {
match self {
NetworkState::Disconnected => "disconnected",
NetworkState::Wired => "wired",
NetworkState::Wireless => "wifi",
NetworkState::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Network {
state: NetworkState,
ssid: Option<String>,
}
impl Network {
pub fn new(state: NetworkState, ssid: Option<&str>) -> Self {
let ssid = if state == NetworkState::Wireless {
ssid.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
} else {
None
};
Self { state, ssid }
}
pub fn state(&self) -> NetworkState {
self.state
}
pub fn ssid(&self) -> Option<&str> {
self.ssid.as_deref()
}
pub fn label(&self) -> String {
match (self.state, self.ssid.as_deref()) {
(NetworkState::Wireless, Some(ssid)) => ssid.to_string(),
(NetworkState::Wireless, None) => String::new(),
_ => self.state.label().to_string(),
}
}
}
fn default_glyph(state: NetworkState) -> &'static str {
match state {
NetworkState::Wireless => WIRELESS_GLYPH,
NetworkState::Wired => WIRED_GLYPH,
NetworkState::Disconnected => DISCONNECTED_GLYPH,
NetworkState::Unknown => UNKNOWN_GLYPH,
}
}
pub struct NetworkWidget {
bounds: Bounds,
state: Option<Network>,
style: WidgetStyle,
on_click: Option<PathBuf>,
on_click_right: Option<PathBuf>,
}
impl NetworkWidget {
pub fn new(bounds: Bounds) -> Self {
Self {
bounds,
state: None,
style: WidgetStyle::default(),
on_click: None,
on_click_right: 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 with_on_click_right(mut self, path: Option<PathBuf>) -> Self {
self.on_click_right = path;
self
}
pub fn label(&self) -> String {
self.state.as_ref().map(Network::label).unwrap_or_default()
}
fn display_text(&self) -> String {
match &self.state {
Some(network) => glyph_label(
self.style.glyph(default_glyph(network.state())),
&network.label(),
),
None => String::new(),
}
}
pub fn tooltip_text(&self) -> Option<String> {
self.state.as_ref().map(|network| match network.state() {
NetworkState::Wireless => match network.ssid() {
Some(ssid) => format!("Connection: Wi-Fi\nSSID: {ssid}"),
None => "Connection: Wi-Fi".to_string(),
},
NetworkState::Wired => "Connection: Wired".to_string(),
NetworkState::Disconnected => "Status: Disconnected".to_string(),
NetworkState::Unknown => "Status: Unknown".to_string(),
})
}
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 NetworkWidget {
fn update(&mut self, msg: &Msg) -> bool {
match msg {
Msg::Network(next) => {
if &self.state == next {
return false;
}
self.state = next.clone();
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: ClickButton) -> Option<Command> {
if !self.contains(px, py) {
return None;
}
let path = match button {
ClickButton::Left => self.on_click.as_ref(),
ClickButton::Right => self.on_click_right.as_ref(),
}?;
Some(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, Command};
use chrono::{Local, TimeZone};
fn network(state: NetworkState, ssid: Option<&str>) -> Msg {
Msg::Network(Some(Network::new(state, ssid)))
}
#[test]
fn state_labels_are_unambiguous_words() {
assert_eq!(NetworkState::Disconnected.label(), "disconnected");
assert_eq!(NetworkState::Wired.label(), "wired");
assert_eq!(NetworkState::Wireless.label(), "wifi");
assert_eq!(NetworkState::Unknown.label(), "unknown");
}
#[test]
fn ssid_is_kept_only_on_a_wireless_link() {
assert_eq!(
Network::new(NetworkState::Wired, Some("home-net")).ssid(),
None
);
assert_eq!(
Network::new(NetworkState::Disconnected, Some("home-net")).ssid(),
None
);
assert_eq!(
Network::new(NetworkState::Unknown, Some("home-net")).ssid(),
None
);
assert_eq!(
Network::new(NetworkState::Wireless, Some("home-net")).ssid(),
Some("home-net")
);
}
#[test]
fn ssid_is_trimmed_and_blank_names_are_dropped() {
assert_eq!(
Network::new(NetworkState::Wireless, Some(" home-net ")).ssid(),
Some("home-net")
);
assert_eq!(Network::new(NetworkState::Wireless, Some("")).ssid(), None);
assert_eq!(
Network::new(NetworkState::Wireless, Some(" ")).ssid(),
None
);
assert_eq!(Network::new(NetworkState::Wireless, None).ssid(), None);
}
#[test]
fn label_shows_only_the_ssid_on_wifi_and_the_state_otherwise() {
assert_eq!(
Network::new(NetworkState::Wireless, Some("home-net")).label(),
"home-net"
);
assert_eq!(Network::new(NetworkState::Wireless, None).label(), "");
assert_eq!(Network::new(NetworkState::Wired, None).label(), "wired");
assert_eq!(
Network::new(NetworkState::Disconnected, None).label(),
"disconnected"
);
assert_eq!(Network::new(NetworkState::Unknown, None).label(), "unknown");
}
#[test]
fn first_reading_changes_state_and_sets_label() {
let mut widget = NetworkWidget::new(Bounds::new(0, 0, 320, 32));
assert_eq!(widget.label(), "");
assert!(widget.update(&network(NetworkState::Wireless, Some("home-net"))));
assert_eq!(widget.label(), "home-net");
}
#[test]
fn identical_reading_is_not_a_visible_change() {
let mut widget = NetworkWidget::new(Bounds::new(0, 0, 320, 32));
assert!(widget.update(&network(NetworkState::Wireless, Some("home-net"))));
assert!(!widget.update(&network(NetworkState::Wireless, Some(" home-net "))));
assert_eq!(widget.label(), "home-net");
}
#[test]
fn a_new_ssid_is_a_visible_change() {
let mut widget = NetworkWidget::new(Bounds::new(0, 0, 320, 32));
assert!(widget.update(&network(NetworkState::Wireless, Some("home-net"))));
assert!(widget.update(&network(NetworkState::Wireless, Some("cafe-net"))));
assert_eq!(widget.label(), "cafe-net");
}
#[test]
fn a_new_state_is_a_visible_change() {
let mut widget = NetworkWidget::new(Bounds::new(0, 0, 320, 32));
assert!(widget.update(&network(NetworkState::Wireless, Some("home-net"))));
assert!(widget.update(&network(NetworkState::Wired, None)));
assert_eq!(widget.label(), "wired");
}
#[test]
fn network_going_absent_is_a_visible_change_then_blank() {
let mut widget = NetworkWidget::new(Bounds::new(0, 0, 320, 32));
assert!(widget.update(&network(NetworkState::Wireless, Some("home-net"))));
assert!(widget.update(&Msg::Network(None)));
assert_eq!(widget.label(), "");
}
#[test]
fn absent_reading_before_any_network_is_not_a_change() {
let mut widget = NetworkWidget::new(Bounds::new(0, 0, 320, 32));
assert!(!widget.update(&Msg::Network(None)));
assert_eq!(widget.label(), "");
}
#[test]
fn unrelated_message_is_ignored() {
let mut widget = NetworkWidget::new(Bounds::new(0, 0, 320, 32));
widget.update(&network(NetworkState::Wired, None));
let tick = Msg::Tick(Local.with_ymd_and_hms(2026, 6, 27, 8, 0, 0).unwrap());
assert!(!widget.update(&tick));
assert_eq!(widget.label(), "wired");
}
#[test]
fn set_bounds_repositions_the_widget() {
let mut widget = NetworkWidget::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_state_driven_glyph() {
let mut widget = NetworkWidget::new(Bounds::new(0, 0, 320, 32));
assert_eq!(widget.display_text(), "");
widget.update(&network(NetworkState::Wireless, Some("home-net")));
assert_eq!(widget.display_text(), format!("{WIRELESS_GLYPH} home-net"));
widget.update(&network(NetworkState::Wireless, None));
assert_eq!(widget.display_text(), WIRELESS_GLYPH);
widget.update(&network(NetworkState::Wired, None));
assert_eq!(widget.display_text(), format!("{WIRED_GLYPH} wired"));
widget.update(&network(NetworkState::Disconnected, None));
assert_eq!(
widget.display_text(),
format!("{DISCONNECTED_GLYPH} disconnected")
);
}
#[test]
fn an_absent_network_measures_zero_a_present_one_reserves_a_slot() {
let mut ctx = RenderContext::new(320, 32);
let mut widget = NetworkWidget::new(Bounds::new(0, 0, 320, 32));
assert_eq!(widget.measure(&mut ctx, 32), 0);
widget.update(&network(NetworkState::Wired, None));
assert!(widget.measure(&mut ctx, 32) > 0);
}
#[test]
fn tooltip_keeps_connection_details_off_the_compact_label() {
let mut widget = NetworkWidget::new(Bounds::new(10, 0, 100, 32));
widget.update(&network(NetworkState::Wireless, Some("home-net")));
assert_eq!(widget.label(), "home-net");
assert_eq!(
widget.tooltip_at(20, 16).map(|tooltip| tooltip.text),
Some("Connection: Wi-Fi\nSSID: home-net".to_string())
);
assert_eq!(widget.tooltip_at(200, 16), None);
}
#[test]
fn left_and_right_clicks_run_their_configured_programs() {
let left = PathBuf::from("networkmanager_dmenu");
let right = PathBuf::from("nm-connection-editor");
let widget = NetworkWidget::new(Bounds::new(10, 0, 100, 32))
.with_on_click(Some(left.clone()))
.with_on_click_right(Some(right.clone()));
assert_eq!(
widget.on_click(20, 16, ClickButton::Left),
Some(Command::RunProgram(left))
);
assert_eq!(
widget.on_click(20, 16, ClickButton::Right),
Some(Command::RunProgram(right))
);
}
#[test]
fn unconfigured_or_outside_clicks_are_ignored() {
let widget = NetworkWidget::new(Bounds::new(10, 0, 100, 32));
assert_eq!(widget.on_click(20, 16, ClickButton::Left), None);
let widget = widget.with_on_click(Some(PathBuf::from("networkmanager_dmenu")));
assert_eq!(widget.on_click(200, 16, ClickButton::Left), None);
}
}