use std::path::PathBuf;
use chrono::{DateTime, Local};
use crate::render::{Bounds, FG, RenderContext};
pub mod backlight;
pub mod battery;
pub mod bluetooth;
pub mod clock;
pub mod hypridle;
pub mod network;
pub mod notifications;
pub mod power;
pub mod power_profiles;
pub mod system;
pub mod title;
pub mod tray;
pub mod updates;
pub mod volume;
pub mod workspaces;
pub use backlight::{Backlight, BacklightWidget};
pub use battery::{Battery, BatteryState, BatteryWidget};
pub use bluetooth::{Bluetooth, BluetoothState, BluetoothWidget};
pub use clock::ClockWidget;
pub use hypridle::{Hypridle, HypridleWidget};
pub use network::{Network, NetworkState, NetworkWidget};
pub use notifications::{Notifications, NotificationsWidget};
pub use power::PowerWidget;
pub use power_profiles::{PowerProfile, PowerProfilesState, PowerProfilesWidget};
pub use system::{SystemStats, SystemWidget};
pub use title::{ActiveWindow, TitleWidget};
pub use tray::{
TrayIcon, TrayItem, TrayMenu, TrayMenuItem, TrayMenuMode, TrayMenuToggle, TrayMenuToggleKind,
TrayMenuToggleState, TrayState, TrayStatus, TrayWidget,
};
pub use updates::{PackageUpdate, PackageUpdates, UpdatesWidget};
pub use volume::{DeviceKind, Volume, VolumeWidget};
pub use workspaces::{WorkspaceWidget, Workspaces};
const ALERT_BACKGROUND: (u8, u8, u8, u8) = (0xBF, 0x61, 0x6A, 0xFF);
const ALERT_FOREGROUND: (u8, u8, u8, u8) = (0xFF, 0xFF, 0xFF, 0xFF);
const DEFAULT_RADIUS: u32 = 8;
const DEFAULT_PADDING: u32 = 8;
const DEFAULT_BORDER_WIDTH: u32 = 1;
const DEFAULT_WARN_THRESHOLD: u32 = 20;
#[non_exhaustive]
#[derive(Debug, Clone)]
pub enum Msg {
Tick(DateTime<Local>),
Workspaces(Workspaces),
Battery(Option<Battery>),
Backlight(Vec<Backlight>),
System(SystemStats),
Network(Option<Network>),
Bluetooth(Bluetooth),
Tray(TrayState),
TrayMenu(TrayMenu),
TrayMenuUnavailable(String),
ActiveWindow {
monitor: String,
window: Option<ActiveWindow>,
},
Volume(Option<Volume>),
Notifications(Option<Notifications>),
PowerProfiles(Option<PowerProfilesState>),
Updates(Option<PackageUpdates>),
Hypridle(Hypridle),
}
impl Msg {
pub fn tick_now() -> Self {
Msg::Tick(Local::now())
}
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq)]
pub enum Command {
SwitchWorkspace(i32),
ActivateTrayItem {
key: String,
x: i32,
y: i32,
},
OpenTrayMenu {
key: String,
x: i32,
y: i32,
},
ActivateTrayMenuItem {
key: String,
id: i32,
},
RunProgram(PathBuf),
ToggleNotificationPanel,
ToggleNotificationsDnd,
AdjustBacklight {
device: String,
direction: ScrollDirection,
step: f64,
},
SetPowerProfile(String),
SetHypridle(bool),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollDirection {
Increase,
Decrease,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClickButton {
Left,
Right,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tooltip {
pub text: String,
pub bounds: Bounds,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum IconSetting {
#[default]
Default,
None,
Custom(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StateColors {
pub background: Option<(u8, u8, u8, u8)>,
pub foreground: (u8, u8, u8, u8),
pub border: Option<(u8, u8, u8, u8)>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct WidgetStyle {
pub background: Option<(u8, u8, u8, u8)>,
pub foreground: (u8, u8, u8, u8),
pub accent: (u8, u8, u8, u8),
pub border: Option<(u8, u8, u8, u8)>,
pub border_width: u32,
pub radius: u32,
pub padding: u32,
pub icon: IconSetting,
pub warn_threshold: u32,
pub warn: StateColors,
pub attention: StateColors,
pub charging: StateColors,
}
impl Default for WidgetStyle {
fn default() -> Self {
Self {
background: None,
foreground: FG,
accent: FG,
border: None,
border_width: DEFAULT_BORDER_WIDTH,
radius: DEFAULT_RADIUS,
padding: DEFAULT_PADDING,
icon: IconSetting::Default,
warn_threshold: DEFAULT_WARN_THRESHOLD,
warn: StateColors {
background: Some(ALERT_BACKGROUND),
foreground: ALERT_FOREGROUND,
border: None,
},
attention: StateColors {
background: Some(ALERT_BACKGROUND),
foreground: ALERT_FOREGROUND,
border: None,
},
charging: StateColors {
background: None,
foreground: FG,
border: None,
},
}
}
}
impl WidgetStyle {
pub fn glyph<'a>(&'a self, default: &'a str) -> &'a str {
match &self.icon {
IconSetting::Default => default,
IconSetting::None => "",
IconSetting::Custom(s) => s,
}
}
pub fn base_colors(&self) -> StateColors {
StateColors {
background: self.background,
foreground: self.foreground,
border: self.border,
}
}
}
pub(crate) fn glyph_label(glyph: &str, label: &str) -> String {
match (glyph.is_empty(), label.is_empty()) {
(true, _) => label.to_string(),
(false, true) => glyph.to_string(),
(false, false) => format!("{glyph} {label}"),
}
}
pub(crate) fn measure_text_pill(ctx: &mut RenderContext, style: &WidgetStyle, text: &str) -> u32 {
if text.is_empty() {
return 0;
}
let pad = style.padding * ctx.scale_factor();
ctx.measure_text(text) + 2 * pad
}
pub(crate) fn draw_text_pill(
ctx: &mut RenderContext,
style: &WidgetStyle,
bounds: Bounds,
text: &str,
colors: StateColors,
) {
if text.is_empty() || bounds.width == 0 || bounds.height == 0 {
return;
}
let scale = ctx.scale_factor();
if let Some(bg) = colors.background {
ctx.fill_rounded_rect(bounds, bg, (style.radius * scale) as f32);
}
if let Some(border) = colors.border.or(style.border) {
ctx.stroke_rounded_rect(
bounds,
border,
(style.radius * scale) as f32,
(style.border_width * scale) as f32,
);
}
let pad = style.padding * scale;
let inner = Bounds::new(
bounds.x + pad,
bounds.y,
bounds.width.saturating_sub(2 * pad),
bounds.height,
);
ctx.draw_text(text, inner, colors.foreground);
}
pub(crate) fn draw_icon_pill(
ctx: &mut RenderContext,
style: &WidgetStyle,
bounds: Bounds,
icon: &str,
colors: StateColors,
) {
if icon.is_empty() || bounds.width == 0 || bounds.height == 0 {
return;
}
let scale = ctx.scale_factor();
if let Some(bg) = colors.background {
ctx.fill_rounded_rect(bounds, bg, (style.radius * scale) as f32);
}
if let Some(border) = colors.border.or(style.border) {
ctx.stroke_rounded_rect(
bounds,
border,
(style.radius * scale) as f32,
(style.border_width * scale) as f32,
);
}
ctx.draw_text_centered(icon, bounds, colors.foreground);
}
pub(crate) fn draw_centered(
ctx: &mut RenderContext,
text: &str,
bounds: Bounds,
color: (u8, u8, u8, u8),
) {
if text.is_empty() || bounds.width == 0 || bounds.height == 0 {
return;
}
let pad = bounds.width.saturating_sub(ctx.measure_text(text)) / 2;
let inner = Bounds::new(
bounds.x + pad,
bounds.y,
bounds.width.saturating_sub(pad),
bounds.height,
);
ctx.draw_text(text, inner, color);
}
pub trait Widget {
fn update(&mut self, msg: &Msg) -> bool;
fn draw(&self, ctx: &mut RenderContext);
fn measure(&self, _ctx: &mut RenderContext, _height: u32) -> u32 {
0
}
fn bounds(&self) -> Bounds;
fn set_bounds(&mut self, bounds: Bounds);
fn on_click(&self, _px: u32, _py: u32, _button: ClickButton) -> Option<Command> {
None
}
fn on_scroll(&self, _px: u32, _py: u32, _direction: ScrollDirection) -> Option<Command> {
None
}
fn tooltip_at(&self, _px: u32, _py: u32) -> Option<Tooltip> {
None
}
}
pub struct Dashboard {
left: Vec<Box<dyn Widget>>,
center: Vec<Box<dyn Widget>>,
right: Vec<Box<dyn Widget>>,
margin: u32,
gap: u32,
}
impl Dashboard {
pub fn new(widgets: Vec<Box<dyn Widget>>) -> Self {
Self {
left: widgets,
center: Vec::new(),
right: Vec::new(),
margin: 0,
gap: 0,
}
}
pub fn with_zones(
left: Vec<Box<dyn Widget>>,
center: Vec<Box<dyn Widget>>,
right: Vec<Box<dyn Widget>>,
) -> Self {
Self {
left,
center,
right,
margin: 0,
gap: 0,
}
}
pub fn with_spacing(mut self, margin: u32, gap: u32) -> Self {
self.margin = margin;
self.gap = gap;
self
}
pub fn update(&mut self, msg: &Msg) -> bool {
let mut dirty = false;
for widget in self
.left
.iter_mut()
.chain(&mut self.center)
.chain(&mut self.right)
{
dirty |= widget.update(msg);
}
dirty
}
pub fn layout(&mut self, ctx: &mut RenderContext, width: u32, height: u32) {
let scale = ctx.scale_factor();
let margin = self.margin * scale;
let gap = self.gap * scale;
let top = margin;
let inner_h = height.saturating_sub(2 * margin);
let left_w = measure_zone(&self.left, ctx, inner_h);
let center_w = measure_zone(&self.center, ctx, inner_h);
let right_w = measure_zone(&self.right, ctx, inner_h);
let left_total = cluster_width(&left_w, gap);
let center_total = cluster_width(¢er_w, gap);
let right_total = cluster_width(&right_w, gap);
let left_start = margin;
place_cluster(&mut self.left, &left_w, left_start, gap, top, inner_h);
let right_edge = width.saturating_sub(margin);
let right_start = right_edge.saturating_sub(right_total);
place_cluster(&mut self.right, &right_w, right_start, gap, top, inner_h);
let lo = if left_total > 0 {
left_start + left_total + gap
} else {
left_start
};
let hi = if right_total > 0 {
right_start.saturating_sub(gap)
} else {
right_edge
};
if center_total > 0 && hi.saturating_sub(lo) >= center_total {
let ideal = width.saturating_sub(center_total) / 2;
let max_start = hi - center_total;
let start = ideal.clamp(lo, max_start);
place_cluster(&mut self.center, ¢er_w, start, gap, top, inner_h);
} else {
let zeros = vec![0u32; center_w.len()];
place_cluster(&mut self.center, &zeros, lo, gap, top, inner_h);
}
}
pub fn draw(&self, ctx: &mut RenderContext) {
ctx.fill_background();
for widget in self.left.iter().chain(&self.center).chain(&self.right) {
widget.draw(ctx);
}
}
pub fn on_click(&self, px: u32, py: u32, button: ClickButton) -> Option<Command> {
self.left
.iter()
.chain(&self.center)
.chain(&self.right)
.find_map(|widget| widget.on_click(px, py, button))
}
pub fn is_clickable_at(&self, px: u32, py: u32) -> bool {
self.on_click(px, py, ClickButton::Left).is_some()
|| self.on_click(px, py, ClickButton::Right).is_some()
}
pub fn on_scroll(&self, px: u32, py: u32, direction: ScrollDirection) -> Option<Command> {
self.left
.iter()
.chain(&self.center)
.chain(&self.right)
.find_map(|widget| widget.on_scroll(px, py, direction))
}
pub fn tooltip_at(&self, px: u32, py: u32) -> Option<Tooltip> {
self.left
.iter()
.chain(&self.center)
.chain(&self.right)
.find_map(|widget| widget.tooltip_at(px, py))
}
}
fn measure_zone(widgets: &[Box<dyn Widget>], ctx: &mut RenderContext, height: u32) -> Vec<u32> {
let mut widths = Vec::with_capacity(widgets.len());
for widget in widgets {
widths.push(widget.measure(ctx, height));
}
widths
}
fn cluster_width(widths: &[u32], gap: u32) -> u32 {
let count = widths.iter().filter(|&&w| w > 0).count() as u32;
let sum: u32 = widths.iter().sum();
sum + gap * count.saturating_sub(1)
}
fn place_cluster(
widgets: &mut [Box<dyn Widget>],
widths: &[u32],
mut x: u32,
gap: u32,
top: u32,
height: u32,
) {
let mut placed = false;
for (widget, &w) in widgets.iter_mut().zip(widths) {
if w == 0 {
widget.set_bounds(Bounds::new(x, top, 0, height));
continue;
}
if placed {
x += gap;
}
widget.set_bounds(Bounds::new(x, top, w, height));
x += w;
placed = true;
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn at(h: u32, m: u32, s: u32) -> Msg {
Msg::Tick(Local.with_ymd_and_hms(2026, 6, 27, h, m, s).unwrap())
}
fn one_workspace(id: i32) -> Box<dyn Widget> {
let mut ws = WorkspaceWidget::new(Bounds::new(0, 0, 1, 1));
ws.update(&Msg::Workspaces(Workspaces::new([id], id)));
Box::new(ws)
}
#[test]
fn update_is_dirty_only_when_a_widget_changes() {
let mut dash = Dashboard::new(vec![Box::new(ClockWidget::new(Bounds::new(0, 0, 320, 32)))]);
assert!(dash.update(&at(12, 0, 0)));
assert!(!dash.update(&at(12, 0, 30)));
assert!(dash.update(&at(12, 1, 0)));
}
#[test]
fn empty_dashboard_never_reports_dirty() {
let mut dash = Dashboard::new(vec![]);
assert!(!dash.update(&at(12, 0, 0)));
}
#[test]
fn layout_packs_left_and_right_zones_to_their_edges() {
let mut dash =
Dashboard::with_zones(vec![one_workspace(1)], vec![], vec![one_workspace(2)]);
let mut ctx = RenderContext::new(200, 32);
dash.layout(&mut ctx, 200, 32);
assert_eq!(dash.left[0].bounds(), Bounds::new(0, 0, 32, 32));
assert_eq!(dash.right[0].bounds(), Bounds::new(168, 0, 32, 32));
}
#[test]
fn layout_centers_the_center_zone_between_the_edges() {
let mut dash = Dashboard::with_zones(vec![], vec![one_workspace(1)], vec![]);
let mut ctx = RenderContext::new(200, 32);
dash.layout(&mut ctx, 200, 32);
assert_eq!(dash.center[0].bounds(), Bounds::new(84, 0, 32, 32));
}
#[test]
fn layout_reserves_the_gap_between_pills_in_a_zone() {
let mut dash =
Dashboard::with_zones(vec![one_workspace(1), one_workspace(2)], vec![], vec![])
.with_spacing(0, 10);
let mut ctx = RenderContext::new(200, 32);
dash.layout(&mut ctx, 200, 32);
assert_eq!(dash.left[0].bounds(), Bounds::new(0, 0, 32, 32));
assert_eq!(dash.left[1].bounds(), Bounds::new(42, 0, 32, 32));
}
#[test]
fn layout_insets_the_row_by_the_bar_margin() {
let mut dash =
Dashboard::with_zones(vec![one_workspace(1)], vec![], vec![]).with_spacing(4, 0);
let mut ctx = RenderContext::new(200, 32);
dash.layout(&mut ctx, 200, 32);
assert_eq!(dash.left[0].bounds(), Bounds::new(4, 4, 24, 24));
}
#[test]
fn an_unmeasured_widget_reserves_no_slot() {
let clock = ClockWidget::new(Bounds::new(0, 0, 1, 1));
let mut dash =
Dashboard::with_zones(vec![Box::new(clock), one_workspace(1)], vec![], vec![]);
let mut ctx = RenderContext::new(200, 32);
dash.layout(&mut ctx, 200, 32);
assert_eq!(dash.left[0].bounds().width, 0);
assert_eq!(dash.left[1].bounds(), Bounds::new(0, 0, 32, 32));
}
#[test]
fn on_click_routes_to_the_zone_that_owns_the_pixel() {
let mut dash = Dashboard::with_zones(
vec![],
vec![Box::new(ClockWidget::new(Bounds::new(0, 0, 1, 1)))],
vec![one_workspace(1)],
);
let mut ctx = RenderContext::new(200, 32);
dash.layout(&mut ctx, 200, 32);
assert_eq!(
dash.on_click(180, 16, ClickButton::Left),
Some(Command::SwitchWorkspace(1))
);
assert_eq!(dash.on_click(100, 16, ClickButton::Left), None);
}
#[test]
fn on_click_threads_the_button_through_to_the_widget() {
let mut dash = Dashboard::with_zones(vec![], vec![], vec![one_workspace(1)]);
let mut ctx = RenderContext::new(200, 32);
dash.layout(&mut ctx, 200, 32);
assert_eq!(
dash.on_click(180, 16, ClickButton::Left),
Some(Command::SwitchWorkspace(1))
);
assert_eq!(dash.on_click(180, 16, ClickButton::Right), None);
}
#[test]
fn clickable_regions_match_widgets_with_click_actions() {
let mut dash = Dashboard::with_zones(vec![], vec![], vec![one_workspace(1)]);
let mut ctx = RenderContext::new(200, 32);
dash.layout(&mut ctx, 200, 32);
assert!(dash.is_clickable_at(180, 16));
assert!(!dash.is_clickable_at(100, 16));
}
#[test]
fn on_click_on_empty_dashboard_is_none() {
let dash = Dashboard::new(vec![]);
assert_eq!(dash.on_click(0, 0, ClickButton::Left), None);
}
#[test]
fn draw_clears_background_each_frame() {
let mut dash = Dashboard::new(vec![Box::new(ClockWidget::new(Bounds::new(0, 0, 320, 32)))]);
dash.update(&at(12, 0, 0));
let mut ctx = RenderContext::new(320, 32);
dash.draw(&mut ctx);
let px = ctx.pixels();
let last = &px[px.len() - 4..];
assert!(last[0] < 0x30 && last[1] < 0x30 && last[2] < 0x30);
assert_eq!(last[3], 0xFF);
}
}