use std::path::{Path, PathBuf};
use chrono::{DateTime, Local};
use serde::Deserialize;
use serde::de::{Deserializer, Error as DeError};
use crate::icon::BuiltinIcon;
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;
pub(crate) const CHARGING_FOREGROUND: (u8, u8, u8, u8) = (0x5F, 0xF5, 0xA0, 0xFF);
#[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(LaunchSpec),
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, PartialEq, Eq)]
pub struct LaunchSpec {
program: PathBuf,
args: Vec<String>,
}
impl LaunchSpec {
pub fn program_only(program: impl Into<PathBuf>) -> Self {
Self {
program: program.into(),
args: Vec::new(),
}
}
pub fn with_args(program: impl Into<PathBuf>, args: impl IntoIterator<Item = String>) -> Self {
Self {
program: program.into(),
args: args.into_iter().collect(),
}
}
pub fn parse(s: &str) -> Result<Self, String> {
let mut tokens = s.split_whitespace();
let Some(program) = tokens.next() else {
return Err("on-click is empty".to_string());
};
Ok(Self {
program: PathBuf::from(program),
args: tokens.map(str::to_owned).collect(),
})
}
pub fn from_argv(argv: Vec<String>) -> Result<Self, String> {
let mut iter = argv.into_iter();
let Some(program) = iter.next() else {
return Err("on-click list is empty".to_string());
};
if program.is_empty() {
return Err("on-click program is empty".to_string());
}
Ok(Self {
program: PathBuf::from(program),
args: iter.collect(),
})
}
pub fn program(&self) -> &Path {
&self.program
}
pub fn args(&self) -> &[String] {
&self.args
}
pub fn display(&self) -> String {
if self.args.is_empty() {
self.program.display().to_string()
} else {
let mut out = self.program.display().to_string();
for arg in &self.args {
out.push(' ');
out.push_str(arg);
}
out
}
}
}
impl From<PathBuf> for LaunchSpec {
fn from(program: PathBuf) -> Self {
Self::program_only(program)
}
}
impl From<&str> for LaunchSpec {
fn from(s: &str) -> Self {
Self::parse(s).unwrap_or_else(|_| Self::program_only(PathBuf::from(s)))
}
}
impl From<String> for LaunchSpec {
fn from(s: String) -> Self {
Self::from(s.as_str())
}
}
impl<'de> Deserialize<'de> for LaunchSpec {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum Raw {
String(String),
List(Vec<String>),
}
match Raw::deserialize(deserializer)? {
Raw::String(s) => Self::parse(&s).map_err(DeError::custom),
Raw::List(list) => Self::from_argv(list).map_err(DeError::custom),
}
}
}
#[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 struct Hover {
pub clickable: bool,
pub tooltip: Option<Tooltip>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum IconSetting {
#[default]
Default,
None,
Custom(String),
}
#[derive(Debug, Clone, PartialEq)]
pub enum ResolvedIcon {
None,
Builtin(BuiltinIcon),
Text(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: CHARGING_FOREGROUND,
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 resolve_icon(&self, default: BuiltinIcon) -> ResolvedIcon {
match &self.icon {
IconSetting::Default => ResolvedIcon::Builtin(default),
IconSetting::None => ResolvedIcon::None,
IconSetting::Custom(s) => ResolvedIcon::Text(s.clone()),
}
}
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);
}
struct ContentBox {
text: Option<String>,
width: u32,
}
fn icon_gap(edge: u32) -> u32 {
(edge / 4).max(1)
}
fn icon_boxes(ctx: &mut RenderContext, edge: u32, template: &str) -> Vec<ContentBox> {
let mut boxes = Vec::new();
let mut rest = template;
loop {
match rest.find("{icon}") {
Some(idx) => {
let text = rest[..idx].trim();
if !text.is_empty() {
let width = ctx.measure_text(text);
boxes.push(ContentBox {
text: Some(text.to_string()),
width,
});
}
boxes.push(ContentBox {
text: None,
width: edge,
});
rest = &rest[idx + "{icon}".len()..];
}
None => {
let text = rest.trim();
if !text.is_empty() {
let width = ctx.measure_text(text);
boxes.push(ContentBox {
text: Some(text.to_string()),
width,
});
}
break;
}
}
}
boxes
}
fn icon_as_text(template: &str, repl: Option<&str>) -> String {
let repl = repl.map(str::trim).filter(|s| !s.is_empty());
let mut parts: Vec<&str> = Vec::new();
let mut rest = template;
loop {
match rest.find("{icon}") {
Some(idx) => {
let before = rest[..idx].trim();
if !before.is_empty() {
parts.push(before);
}
if let Some(repl) = repl {
parts.push(repl);
}
rest = &rest[idx + "{icon}".len()..];
}
None => {
let last = rest.trim();
if !last.is_empty() {
parts.push(last);
}
break;
}
}
}
parts.join(" ")
}
fn is_icon_only(template: &str) -> bool {
template.contains("{icon}") && template.replace("{icon}", " ").trim().is_empty()
}
pub(crate) fn measure_icon_content(
ctx: &mut RenderContext,
style: &WidgetStyle,
icon: &ResolvedIcon,
template: &str,
) -> u32 {
match icon {
ResolvedIcon::Builtin(_) => {
let edge = ctx.icon_edge();
let boxes = icon_boxes(ctx, edge, template);
if boxes.is_empty() {
return 0;
}
let gap = icon_gap(edge);
let content: u32 =
boxes.iter().map(|b| b.width).sum::<u32>() + gap * (boxes.len() as u32 - 1);
content + 2 * style.padding * ctx.scale_factor()
}
ResolvedIcon::None => measure_text_pill(ctx, style, &icon_as_text(template, None)),
ResolvedIcon::Text(s) => measure_text_pill(ctx, style, &icon_as_text(template, Some(s))),
}
}
pub(crate) fn draw_icon_content(
ctx: &mut RenderContext,
style: &WidgetStyle,
bounds: Bounds,
icon: &ResolvedIcon,
template: &str,
colors: StateColors,
) {
match icon {
ResolvedIcon::Builtin(builtin) => {
draw_builtin_content(ctx, style, bounds, &[*builtin], template, colors);
}
ResolvedIcon::None => {
draw_text_pill(ctx, style, bounds, &icon_as_text(template, None), colors);
}
ResolvedIcon::Text(s) => {
let text = icon_as_text(template, Some(s));
if is_icon_only(template) {
draw_icon_pill(ctx, style, bounds, &text, colors);
} else {
draw_text_pill(ctx, style, bounds, &text, colors);
}
}
}
}
pub(crate) fn draw_builtin_icons(
ctx: &mut RenderContext,
style: &WidgetStyle,
bounds: Bounds,
icons: &[BuiltinIcon],
template: &str,
colors: StateColors,
) {
draw_builtin_content(ctx, style, bounds, icons, template, colors);
}
fn draw_builtin_content(
ctx: &mut RenderContext,
style: &WidgetStyle,
bounds: Bounds,
icons: &[BuiltinIcon],
template: &str,
colors: StateColors,
) {
if bounds.width == 0 || bounds.height == 0 || icons.is_empty() {
return;
}
let edge = ctx.icon_edge();
let boxes = icon_boxes(ctx, edge, template);
if boxes.is_empty() {
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 gap = icon_gap(edge);
let pad = style.padding * scale;
let mut x = bounds.x + pad;
let mut icon_slot = 0;
for (index, content) in boxes.iter().enumerate() {
if index > 0 {
x += gap;
}
match &content.text {
None => {
let builtin = icons[icon_slot.min(icons.len() - 1)];
icon_slot += 1;
let iy = bounds.y + bounds.height.saturating_sub(edge) / 2;
ctx.draw_builtin_icon(builtin, Bounds::new(x, iy, edge, edge), colors.foreground);
}
Some(text) => {
ctx.draw_text(
text,
Bounds::new(x, bounds.y, content.width, bounds.height),
colors.foreground,
);
}
}
x += content.width;
}
}
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,
changed: Vec<bool>,
painted: Option<Vec<Bounds>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Damage {
Full,
Region(Bounds),
}
impl Dashboard {
pub fn new(widgets: Vec<Box<dyn Widget>>) -> Self {
Self::with_zones(widgets, Vec::new(), Vec::new())
}
pub fn with_zones(
left: Vec<Box<dyn Widget>>,
center: Vec<Box<dyn Widget>>,
right: Vec<Box<dyn Widget>>,
) -> Self {
let widgets = left.len() + center.len() + right.len();
Self {
left,
center,
right,
margin: 0,
gap: 0,
changed: vec![false; widgets],
painted: None,
}
}
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, changed) in self
.left
.iter_mut()
.chain(&mut self.center)
.chain(&mut self.right)
.zip(&mut self.changed)
{
let updated = widget.update(msg);
*changed |= updated;
dirty |= updated;
}
dirty
}
pub fn take_damage(&mut self) -> Damage {
let bounds: Vec<Bounds> = self.widgets().map(|widget| widget.bounds()).collect();
let region = if self.painted.as_ref() == Some(&bounds) {
bounds
.iter()
.zip(&self.changed)
.filter(|(_, changed)| **changed)
.map(|(bounds, _)| *bounds)
.reduce(|damage, bounds| damage.union(&bounds))
} else {
None
};
self.painted = Some(bounds);
self.changed.fill(false);
region.map_or(Damage::Full, Damage::Region)
}
pub fn invalidate(&mut self) {
self.painted = None;
}
fn widgets(&self) -> impl Iterator<Item = &Box<dyn Widget>> {
self.left.iter().chain(&self.center).chain(&self.right)
}
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 hover_at(&self, px: u32, py: u32) -> Hover {
let Some(widget) = self
.widgets()
.find(|widget| widget.bounds().contains(px, py))
else {
return Hover::default();
};
Hover {
clickable: widget.on_click(px, py, ClickButton::Left).is_some()
|| widget.on_click(px, py, ClickButton::Right).is_some(),
tooltip: widget.tooltip_at(px, py),
}
}
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))
}
}
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.hover_at(180, 16).clickable);
assert!(!dash.hover_at(100, 16).clickable);
}
fn hoverable_dashboard() -> Dashboard {
let mut workspaces = WorkspaceWidget::new(Bounds::new(0, 0, 1, 1));
workspaces.update(&Msg::Workspaces(Workspaces::new([1, 2], 1)));
let mut dash = Dashboard::with_zones(
vec![Box::new(workspaces)],
vec![Box::new(ClockWidget::new(Bounds::new(0, 0, 1, 1)))],
vec![Box::new(HypridleWidget::new(Bounds::new(0, 0, 1, 1)))],
);
dash.update(&at(12, 0, 0));
dash.update(&Msg::Hypridle(Hypridle::new(true)));
dash.layout(&mut RenderContext::new(400, 32), 400, 32);
dash
}
#[test]
fn hovering_a_widget_reports_its_click_action_and_tooltip_together() {
let dash = hoverable_dashboard();
let hypridle = dash.right[0].bounds();
let (px, py) = (
hypridle.x + hypridle.width / 2,
hypridle.y + hypridle.height / 2,
);
let hover = dash.hover_at(px, py);
assert!(hover.clickable);
assert_eq!(
hover.tooltip,
Some(Tooltip {
text: "Hypridle active".to_owned(),
bounds: hypridle,
})
);
}
#[test]
fn hovering_empty_bar_space_reports_nothing() {
let dash = hoverable_dashboard();
let gap = dash.left[0].bounds().x + dash.left[0].bounds().width + 1;
assert!(
!dash.center[0].bounds().contains(gap, 16),
"probe is in a gap"
);
assert_eq!(dash.hover_at(gap, 16), Hover::default());
assert_eq!(Dashboard::new(vec![]).hover_at(0, 0), Hover::default());
}
#[test]
fn hover_agrees_with_asking_every_widget_at_every_pixel() {
let dash = hoverable_dashboard();
for py in 0..32 {
for px in 0..400 {
let exhaustive = Hover {
clickable: dash.on_click(px, py, ClickButton::Left).is_some()
|| dash.on_click(px, py, ClickButton::Right).is_some(),
tooltip: dash.widgets().find_map(|widget| widget.tooltip_at(px, py)),
};
assert_eq!(dash.hover_at(px, py), exhaustive, "at ({px}, {py})");
}
}
}
#[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);
}
fn workspaces_and_clock() -> (Dashboard, RenderContext) {
let mut workspaces = WorkspaceWidget::new(Bounds::new(0, 0, 1, 1));
workspaces.update(&Msg::Workspaces(Workspaces::new([1, 2], 1)));
let mut dash = Dashboard::with_zones(
vec![Box::new(workspaces)],
vec![],
vec![Box::new(ClockWidget::new(Bounds::new(0, 0, 1, 1)))],
);
dash.update(&at(12, 0, 0));
(dash, RenderContext::new(400, 32))
}
fn frame(dash: &mut Dashboard, ctx: &mut RenderContext) -> (Damage, Vec<u8>) {
dash.layout(ctx, 400, 32);
let damage = dash.take_damage();
dash.draw(ctx);
(damage, ctx.pixels().to_vec())
}
#[test]
fn the_first_frame_damages_the_whole_bar() {
let (mut dash, mut ctx) = workspaces_and_clock();
assert_eq!(frame(&mut dash, &mut ctx).0, Damage::Full);
}
#[test]
fn a_change_inside_fixed_slots_damages_only_the_changed_widgets() {
let (mut dash, mut ctx) = workspaces_and_clock();
frame(&mut dash, &mut ctx);
assert!(dash.update(&Msg::Workspaces(Workspaces::new([1, 2], 2))));
let workspaces = dash.left[0].bounds();
assert_eq!(frame(&mut dash, &mut ctx).0, Damage::Region(workspaces));
}
#[test]
fn widgets_changed_by_coalesced_messages_damage_the_union_of_their_slots() {
let strip = |active| {
let mut widget = WorkspaceWidget::new(Bounds::new(0, 0, 1, 1));
widget.update(&Msg::Workspaces(Workspaces::new([1, 2], active)));
widget
};
let mut dash =
Dashboard::with_zones(vec![Box::new(strip(1))], vec![], vec![Box::new(strip(1))]);
let mut ctx = RenderContext::new(400, 32);
frame(&mut dash, &mut ctx);
assert!(dash.update(&Msg::Workspaces(Workspaces::new([1, 2], 2))));
let (damage, _) = frame(&mut dash, &mut ctx);
let (left, right) = (dash.left[0].bounds(), dash.right[0].bounds());
assert_eq!(damage, Damage::Region(left.union(&right)));
}
#[test]
fn every_pixel_that_differs_between_frames_lies_inside_the_damage() {
let (mut dash, mut ctx) = workspaces_and_clock();
let (_, before) = frame(&mut dash, &mut ctx);
dash.update(&Msg::Workspaces(Workspaces::new([1, 2], 2)));
let (damage, after) = frame(&mut dash, &mut ctx);
let Damage::Region(region) = damage else {
panic!("fixed slots should give partial damage, got {damage:?}");
};
let differing: Vec<(u32, u32)> = before
.as_chunks::<4>()
.0
.iter()
.zip(after.as_chunks::<4>().0)
.enumerate()
.filter(|(_, (before, after))| before != after)
.map(|(i, _)| (i as u32 % 400, i as u32 / 400))
.collect();
assert!(!differing.is_empty(), "the active workspace moved");
assert!(
differing.iter().all(|&(x, y)| region.contains(x, y)),
"pixels outside {region:?} changed"
);
}
#[test]
fn a_slot_that_moves_or_resizes_damages_the_whole_bar() {
let (mut dash, mut ctx) = workspaces_and_clock();
frame(&mut dash, &mut ctx);
dash.update(&Msg::Workspaces(Workspaces::new([1, 2, 3], 1)));
assert_eq!(frame(&mut dash, &mut ctx).0, Damage::Full);
}
#[test]
fn a_repaint_nothing_asked_for_and_an_invalidated_frame_damage_the_whole_bar() {
let (mut dash, mut ctx) = workspaces_and_clock();
frame(&mut dash, &mut ctx);
assert_eq!(frame(&mut dash, &mut ctx).0, Damage::Full);
dash.update(&Msg::Workspaces(Workspaces::new([1, 2], 2)));
dash.invalidate();
assert_eq!(frame(&mut dash, &mut ctx).0, Damage::Full);
}
#[test]
fn bounds_union_covers_both_rectangles() {
let a = Bounds::new(10, 4, 20, 8);
let b = Bounds::new(50, 0, 5, 30);
assert_eq!(a.union(&b), Bounds::new(10, 0, 45, 30));
assert_eq!(a.union(&a), a);
}
}