use std::collections::BTreeMap;
use std::fmt;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use serde::de::{Deserializer, Error as _};
use crate::render::{Bounds, RenderSettings};
use crate::scale::Scale;
use crate::widget::{
BacklightWidget, BatteryWidget, BluetoothWidget, ClockWidget, Dashboard, IconSetting,
NetworkWidget, NotificationsWidget, PowerProfilesWidget, StateColors, SystemWidget,
TitleWidget, TrayWidget, UpdatesWidget, VolumeWidget, Widget, WidgetStyle, WorkspaceWidget,
backlight::validate_backlight_format, battery::validate_battery_format,
power_profiles::validate_power_profiles_format, updates::validate_updates_format,
};
const DEFAULT_HEIGHT: u32 = 32;
const DEFAULT_FONT_SIZE: f32 = 16.0;
const DEFAULT_BACKGROUND: Color = Color::rgb(0x18, 0x18, 0x18);
const DEFAULT_FOREGROUND: Color = Color::rgb(0xEA, 0xEA, 0xEA);
const MIN_HEIGHT: u32 = 1;
const MAX_HEIGHT: u32 = 4096;
const MIN_FONT_SIZE: f32 = 1.0;
const MAX_FONT_SIZE: f32 = 512.0;
const MAX_GAP: u32 = 4096;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Color {
pub r: u8,
pub g: u8,
pub b: u8,
pub a: u8,
}
impl Color {
pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
Self { r, g, b, a: 0xFF }
}
pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
Self { r, g, b, a }
}
pub fn to_rgb(self) -> (u8, u8, u8) {
(self.r, self.g, self.b)
}
pub fn to_rgba(self) -> (u8, u8, u8, u8) {
(self.r, self.g, self.b, self.a)
}
pub fn parse_hex(s: &str) -> Result<Color, String> {
let hex = s.strip_prefix('#').unwrap_or(s);
let valid_len = hex.len() == 6 || hex.len() == 8;
if !valid_len || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(format!(
"invalid color {s:?}: expected a \"#rrggbb\" or \"#rrggbbaa\" hex string"
));
}
let channel = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).expect("validated hex");
let a = if hex.len() == 8 { channel(6) } else { 0xFF };
Ok(Color::rgba(channel(0), channel(2), channel(4), a))
}
}
impl<'de> Deserialize<'de> for Color {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
Color::parse_hex(&raw).map_err(D::Error::custom)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Theme {
pub background: Color,
pub foreground: Color,
pub accent: Color,
}
impl Default for Theme {
fn default() -> Self {
Self {
background: DEFAULT_BACKGROUND,
foreground: DEFAULT_FOREGROUND,
accent: DEFAULT_FOREGROUND,
}
}
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Font {
pub family: Option<String>,
pub size: f32,
}
impl Default for Font {
fn default() -> Self {
Self {
family: None,
size: DEFAULT_FONT_SIZE,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum WidgetKind {
Workspaces,
Clock,
Battery,
Backlight,
System,
Network,
Tray,
Title,
Bluetooth,
Volume,
Notifications,
#[serde(rename = "power-profiles-daemon")]
PowerProfilesDaemon,
Updates,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Bar {
pub background: Option<Color>,
pub margin: u32,
pub gap: u32,
#[serde(rename = "modules-left")]
pub modules_left: Vec<WidgetKind>,
#[serde(rename = "modules-center")]
pub modules_center: Vec<WidgetKind>,
#[serde(rename = "modules-right")]
pub modules_right: Vec<WidgetKind>,
}
impl Default for Bar {
fn default() -> Self {
Self {
background: None,
margin: 0,
gap: 0,
modules_left: vec![WidgetKind::Workspaces],
modules_center: vec![WidgetKind::Title],
modules_right: vec![
WidgetKind::Clock,
WidgetKind::Battery,
WidgetKind::System,
WidgetKind::Network,
WidgetKind::PowerProfilesDaemon,
],
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct StateColorConfig {
pub background: Option<Color>,
pub foreground: Option<Color>,
pub border: Option<Color>,
}
impl StateColorConfig {
fn apply(&self, base: &mut StateColorConfig) {
if self.background.is_some() {
base.background = self.background;
}
if self.foreground.is_some() {
base.foreground = self.foreground;
}
if self.border.is_some() {
base.border = self.border;
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct WidgetStyleConfig {
pub background: Option<Color>,
pub foreground: Option<Color>,
pub accent: Option<Color>,
pub border: Option<Color>,
#[serde(rename = "border-width")]
pub border_width: Option<u32>,
pub radius: Option<u32>,
pub padding: Option<u32>,
pub icon: Option<String>,
#[serde(rename = "warn-threshold")]
pub warn_threshold: Option<u32>,
pub warn: StateColorConfig,
pub attention: StateColorConfig,
pub charging: StateColorConfig,
#[serde(rename = "on-click")]
pub on_click: Option<std::path::PathBuf>,
pub format: Option<String>,
#[serde(rename = "format-icons")]
pub format_icons: Option<FormatIconsConfig>,
#[serde(rename = "scroll-step")]
pub scroll_step: Option<f64>,
pub device: Option<String>,
pub tooltip: Option<bool>,
#[serde(rename = "tooltip-format")]
pub tooltip_format: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(untagged)]
pub enum FormatIconsConfig {
Ramp(Vec<String>),
Named(BTreeMap<String, String>),
}
impl FormatIconsConfig {
fn ramp(&self) -> Option<&Vec<String>> {
match self {
Self::Ramp(icons) => Some(icons),
Self::Named(_) => None,
}
}
fn named(&self) -> Option<&BTreeMap<String, String>> {
match self {
Self::Named(icons) => Some(icons),
Self::Ramp(_) => None,
}
}
}
impl WidgetStyleConfig {
fn resolve(&self, theme: &Theme) -> WidgetStyle {
let defaults = WidgetStyle::default();
let background = self.background.map(Color::to_rgba);
let foreground = self
.foreground
.map(Color::to_rgba)
.unwrap_or_else(|| theme.foreground.to_rgba());
let border = self.border.map(Color::to_rgba);
WidgetStyle {
background,
foreground,
accent: self
.accent
.map(Color::to_rgba)
.unwrap_or_else(|| theme.accent.to_rgba()),
border,
border_width: self.border_width.unwrap_or(defaults.border_width),
radius: self.radius.unwrap_or(defaults.radius),
padding: self.padding.unwrap_or(defaults.padding),
icon: resolve_icon(self.icon.as_deref()),
warn_threshold: self.warn_threshold.unwrap_or(defaults.warn_threshold),
warn: resolve_state(self.warn, defaults.warn),
attention: resolve_state(self.attention, defaults.attention),
charging: resolve_state(
self.charging,
StateColors {
background,
foreground,
border,
},
),
}
}
fn apply(&self, base: &mut WidgetStyleConfig) {
if self.background.is_some() {
base.background = self.background;
}
if self.foreground.is_some() {
base.foreground = self.foreground;
}
if self.accent.is_some() {
base.accent = self.accent;
}
if self.border.is_some() {
base.border = self.border;
}
if self.border_width.is_some() {
base.border_width = self.border_width;
}
if self.radius.is_some() {
base.radius = self.radius;
}
if self.padding.is_some() {
base.padding = self.padding;
}
if self.icon.is_some() {
base.icon = self.icon.clone();
}
if self.warn_threshold.is_some() {
base.warn_threshold = self.warn_threshold;
}
if self.on_click.is_some() {
base.on_click = self.on_click.clone();
}
if self.format.is_some() {
base.format = self.format.clone();
}
if self.format_icons.is_some() {
base.format_icons = self.format_icons.clone();
}
if self.scroll_step.is_some() {
base.scroll_step = self.scroll_step;
}
if self.device.is_some() {
base.device = self.device.clone();
}
if self.tooltip.is_some() {
base.tooltip = self.tooltip;
}
if self.tooltip_format.is_some() {
base.tooltip_format = self.tooltip_format.clone();
}
self.warn.apply(&mut base.warn);
self.attention.apply(&mut base.attention);
self.charging.apply(&mut base.charging);
}
}
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct WidgetStyles {
pub workspaces: WidgetStyleConfig,
pub clock: WidgetStyleConfig,
pub battery: WidgetStyleConfig,
pub backlight: WidgetStyleConfig,
pub system: WidgetStyleConfig,
pub network: WidgetStyleConfig,
pub tray: WidgetStyleConfig,
pub title: WidgetStyleConfig,
pub bluetooth: WidgetStyleConfig,
pub volume: WidgetStyleConfig,
pub notifications: WidgetStyleConfig,
#[serde(rename = "power-profiles-daemon")]
pub power_profiles_daemon: WidgetStyleConfig,
pub updates: WidgetStyleConfig,
}
impl WidgetStyles {
fn get(&self, kind: WidgetKind) -> &WidgetStyleConfig {
match kind {
WidgetKind::Workspaces => &self.workspaces,
WidgetKind::Clock => &self.clock,
WidgetKind::Battery => &self.battery,
WidgetKind::Backlight => &self.backlight,
WidgetKind::System => &self.system,
WidgetKind::Network => &self.network,
WidgetKind::Tray => &self.tray,
WidgetKind::Title => &self.title,
WidgetKind::Bluetooth => &self.bluetooth,
WidgetKind::Volume => &self.volume,
WidgetKind::Notifications => &self.notifications,
WidgetKind::PowerProfilesDaemon => &self.power_profiles_daemon,
WidgetKind::Updates => &self.updates,
}
}
fn apply(&self, base: &mut WidgetStyles) {
self.workspaces.apply(&mut base.workspaces);
self.clock.apply(&mut base.clock);
self.battery.apply(&mut base.battery);
self.backlight.apply(&mut base.backlight);
self.system.apply(&mut base.system);
self.network.apply(&mut base.network);
self.tray.apply(&mut base.tray);
self.title.apply(&mut base.title);
self.bluetooth.apply(&mut base.bluetooth);
self.volume.apply(&mut base.volume);
self.notifications.apply(&mut base.notifications);
self.power_profiles_daemon
.apply(&mut base.power_profiles_daemon);
self.updates.apply(&mut base.updates);
}
}
fn resolve_icon(icon: Option<&str>) -> IconSetting {
match icon {
None => IconSetting::Default,
Some("none") => IconSetting::None,
Some(custom) => IconSetting::Custom(custom.to_string()),
}
}
fn resolve_state(config: StateColorConfig, default: StateColors) -> StateColors {
StateColors {
background: match config.background {
Some(color) => Some(color.to_rgba()),
None => default.background,
},
foreground: config
.foreground
.map(Color::to_rgba)
.unwrap_or(default.foreground),
border: config.border.map(Color::to_rgba).or(default.border),
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ThemeOverride {
pub background: Option<Color>,
pub foreground: Option<Color>,
pub accent: Option<Color>,
}
impl ThemeOverride {
fn apply(self, base: &mut Theme) {
if let Some(background) = self.background {
base.background = background;
}
if let Some(foreground) = self.foreground {
base.foreground = foreground;
}
if let Some(accent) = self.accent {
base.accent = accent;
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct FontOverride {
pub family: Option<String>,
pub size: Option<f32>,
}
impl FontOverride {
fn apply(&self, base: &mut Font) {
if let Some(family) = &self.family {
base.family = Some(family.clone());
}
if let Some(size) = self.size {
base.size = size;
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct BarOverride {
pub background: Option<Color>,
pub margin: Option<u32>,
pub gap: Option<u32>,
#[serde(rename = "modules-left")]
pub modules_left: Option<Vec<WidgetKind>>,
#[serde(rename = "modules-center")]
pub modules_center: Option<Vec<WidgetKind>>,
#[serde(rename = "modules-right")]
pub modules_right: Option<Vec<WidgetKind>>,
}
impl BarOverride {
fn apply(&self, base: &mut Bar) {
if self.background.is_some() {
base.background = self.background;
}
if let Some(margin) = self.margin {
base.margin = margin;
}
if let Some(gap) = self.gap {
base.gap = gap;
}
if let Some(left) = &self.modules_left {
base.modules_left = left.clone();
}
if let Some(center) = &self.modules_center {
base.modules_center = center.clone();
}
if let Some(right) = &self.modules_right {
base.modules_right = right.clone();
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct MonitorConfig {
pub name: String,
pub height: Option<u32>,
pub theme: ThemeOverride,
pub font: FontOverride,
pub bar: BarOverride,
pub widget: WidgetStyles,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub height: u32,
pub theme: Theme,
pub font: Font,
pub bar: Bar,
pub widget: WidgetStyles,
#[serde(rename = "monitor")]
pub monitors: Vec<MonitorConfig>,
}
impl Default for Config {
fn default() -> Self {
Self {
height: DEFAULT_HEIGHT,
theme: Theme::default(),
font: Font::default(),
bar: Bar::default(),
widget: WidgetStyles::default(),
monitors: Vec::new(),
}
}
}
impl Config {
pub fn from_toml_str(toml: &str) -> Result<Config, ConfigError> {
let config: Config =
toml::from_str(toml).map_err(|source| ConfigError::Parse { path: None, source })?;
config.validate().map_err(|message| ConfigError::Invalid {
path: None,
message,
})?;
Ok(config)
}
pub fn load_from_path(path: impl AsRef<Path>) -> Result<Config, ConfigError> {
let path = path.as_ref();
match fs::read_to_string(path) {
Ok(text) => {
let config: Config =
toml::from_str(&text).map_err(|source| ConfigError::Parse {
path: Some(path.to_path_buf()),
source,
})?;
config.validate().map_err(|message| ConfigError::Invalid {
path: Some(path.to_path_buf()),
message,
})?;
Ok(config)
}
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Config::default()),
Err(source) => Err(ConfigError::Read {
path: path.to_path_buf(),
source,
}),
}
}
pub fn validate(&self) -> Result<(), String> {
validate_range("height", self.height, MIN_HEIGHT, MAX_HEIGHT)?;
validate_font_size("font.size", self.font.size)?;
validate_range("bar.margin", self.bar.margin, 0, MAX_GAP)?;
validate_range("bar.gap", self.bar.gap, 0, MAX_GAP)?;
validate_battery_config("widget.battery", &self.widget.battery)?;
validate_backlight_config("widget.backlight", &self.widget.backlight)?;
validate_power_profiles_config(
"widget.power-profiles-daemon",
&self.widget.power_profiles_daemon,
)?;
validate_updates_config("widget.updates", &self.widget.updates)?;
for monitor in &self.monitors {
if monitor.name.trim().is_empty() {
return Err(
"a [[monitor]] override has an empty `name`; it can never match an output"
.to_string(),
);
}
let who = monitor.name.as_str();
if let Some(height) = monitor.height {
validate_range(
&format!("monitor {who:?} height"),
height,
MIN_HEIGHT,
MAX_HEIGHT,
)?;
}
if let Some(size) = monitor.font.size {
validate_font_size(&format!("monitor {who:?} font.size"), size)?;
}
if let Some(margin) = monitor.bar.margin {
validate_range(&format!("monitor {who:?} bar.margin"), margin, 0, MAX_GAP)?;
}
if let Some(gap) = monitor.bar.gap {
validate_range(&format!("monitor {who:?} bar.gap"), gap, 0, MAX_GAP)?;
}
validate_backlight_config(
&format!("monitor {who:?} widget.backlight"),
&monitor.widget.backlight,
)?;
validate_battery_config(
&format!("monitor {who:?} widget.battery"),
&monitor.widget.battery,
)?;
validate_power_profiles_config(
&format!("monitor {who:?} widget.power-profiles-daemon"),
&monitor.widget.power_profiles_daemon,
)?;
validate_updates_config(
&format!("monitor {who:?} widget.updates"),
&monitor.widget.updates,
)?;
}
Ok(())
}
pub fn resolved_zones(&self) -> ResolvedZones {
let mut seen: Vec<WidgetKind> = Vec::new();
let left = dedup_zone(&self.bar.modules_left, &mut seen);
let center = dedup_zone(&self.bar.modules_center, &mut seen);
let right = dedup_zone(&self.bar.modules_right, &mut seen);
ResolvedZones {
left,
center,
right,
}
}
pub fn uses_widget(&self, kind: WidgetKind) -> bool {
let bar_contains = |bar: &Bar| {
bar.modules_left.contains(&kind)
|| bar.modules_center.contains(&kind)
|| bar.modules_right.contains(&kind)
};
bar_contains(&self.bar)
|| self.monitors.iter().any(|monitor| {
monitor
.bar
.modules_left
.as_ref()
.is_some_and(|zone| zone.contains(&kind))
|| monitor
.bar
.modules_center
.as_ref()
.is_some_and(|zone| zone.contains(&kind))
|| monitor
.bar
.modules_right
.as_ref()
.is_some_and(|zone| zone.contains(&kind))
})
}
pub fn resolve_for_output(&self, output: Option<&str>) -> Config {
let mut resolved = self.clone();
resolved.monitors = Vec::new();
let Some(name) = output else {
return resolved;
};
let Some(monitor) = self.monitors.iter().find(|m| m.name == name) else {
return resolved;
};
if let Some(height) = monitor.height {
resolved.height = height;
}
monitor.theme.apply(&mut resolved.theme);
monitor.font.apply(&mut resolved.font);
monitor.bar.apply(&mut resolved.bar);
monitor.widget.apply(&mut resolved.widget);
resolved
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedZones {
pub left: Vec<WidgetKind>,
pub center: Vec<WidgetKind>,
pub right: Vec<WidgetKind>,
}
fn dedup_zone(zone: &[WidgetKind], seen: &mut Vec<WidgetKind>) -> Vec<WidgetKind> {
let mut resolved = Vec::new();
for &kind in zone {
if !seen.contains(&kind) {
seen.push(kind);
resolved.push(kind);
}
}
resolved
}
fn validate_range(field: &str, value: u32, min: u32, max: u32) -> Result<(), String> {
if (min..=max).contains(&value) {
Ok(())
} else {
Err(format!(
"{field} must be between {min} and {max}, got {value}"
))
}
}
fn validate_font_size(field: &str, size: f32) -> Result<(), String> {
if size.is_finite() && (MIN_FONT_SIZE..=MAX_FONT_SIZE).contains(&size) {
Ok(())
} else {
Err(format!(
"{field} must be a finite number between {MIN_FONT_SIZE} and {MAX_FONT_SIZE}, got {size}"
))
}
}
fn validate_backlight_config(field: &str, config: &WidgetStyleConfig) -> Result<(), String> {
if let Some(format) = &config.format {
validate_backlight_format(format).map_err(|error| format!("{field}.format {error}"))?;
}
if let Some(icons) = &config.format_icons {
let Some(icons) = icons.ramp() else {
return Err(format!("{field}.format-icons must be an icon array"));
};
if icons.is_empty() || icons.iter().any(String::is_empty) {
return Err(format!("{field}.format-icons must contain non-empty icons"));
}
}
if let Some(step) = config.scroll_step
&& (!step.is_finite() || step <= 0.0 || step > 100.0)
{
return Err(format!(
"{field}.scroll-step must be a finite number between 0 and 100, got {step}"
));
}
if let Some(device) = &config.device
&& (device.trim().is_empty() || device.contains('/') || device == "." || device == "..")
{
return Err(format!(
"{field}.device must be a non-empty sysfs device name, got {device:?}"
));
}
Ok(())
}
fn validate_battery_config(field: &str, config: &WidgetStyleConfig) -> Result<(), String> {
if let Some(format) = &config.format {
validate_battery_format(format).map_err(|error| format!("{field}.format {error}"))?;
}
Ok(())
}
fn validate_power_profiles_config(field: &str, config: &WidgetStyleConfig) -> Result<(), String> {
if let Some(format) = &config.format {
validate_power_profiles_format(format)
.map_err(|error| format!("{field}.format {error}"))?;
}
if let Some(format) = &config.tooltip_format {
validate_power_profiles_format(format)
.map_err(|error| format!("{field}.tooltip-format {error}"))?;
}
if let Some(icons) = &config.format_icons {
let Some(icons) = icons.named() else {
return Err(format!("{field}.format-icons must be a named icon table"));
};
if icons.is_empty()
|| icons
.iter()
.any(|(name, icon)| name.trim().is_empty() || icon.is_empty())
{
return Err(format!(
"{field}.format-icons must contain non-empty names and icons"
));
}
}
Ok(())
}
fn validate_updates_config(field: &str, config: &WidgetStyleConfig) -> Result<(), String> {
if let Some(format) = &config.format {
validate_updates_format(format).map_err(|error| format!("{field}.format {error}"))?;
}
Ok(())
}
impl WidgetKind {
pub fn build(
self,
bounds: Bounds,
style: WidgetStyle,
monitor: Option<&str>,
on_click: Option<std::path::PathBuf>,
) -> Box<dyn Widget> {
match self {
WidgetKind::Workspaces => match monitor {
Some(name) => {
Box::new(WorkspaceWidget::for_monitor(bounds, name).with_style(style))
}
None => Box::new(WorkspaceWidget::new(bounds).with_style(style)),
},
WidgetKind::Clock => Box::new(ClockWidget::new(bounds).with_style(style)),
WidgetKind::Battery => Box::new(BatteryWidget::new(bounds).with_style(style)),
WidgetKind::Backlight => Box::new(BacklightWidget::new(bounds).with_style(style)),
WidgetKind::System => Box::new(SystemWidget::new(bounds).with_style(style)),
WidgetKind::Network => Box::new(NetworkWidget::new(bounds).with_style(style)),
WidgetKind::Tray => Box::new(TrayWidget::new(bounds).with_style(style)),
WidgetKind::Title => Box::new(
TitleWidget::new(bounds)
.with_style(style)
.with_monitor(monitor.unwrap_or("")),
),
WidgetKind::Bluetooth => Box::new(
BluetoothWidget::new(bounds)
.with_style(style)
.with_on_click(on_click),
),
WidgetKind::Volume => Box::new(
VolumeWidget::new(bounds)
.with_style(style)
.with_on_click(on_click),
),
WidgetKind::Notifications => {
Box::new(NotificationsWidget::new(bounds).with_style(style))
}
WidgetKind::PowerProfilesDaemon => {
Box::new(PowerProfilesWidget::new(bounds).with_style(style))
}
WidgetKind::Updates => Box::new(
UpdatesWidget::new(bounds)
.with_style(style)
.with_on_click(on_click),
),
}
}
}
impl Config {
pub fn render_settings(&self) -> RenderSettings {
RenderSettings {
background: self
.bar
.background
.unwrap_or(self.theme.background)
.to_rgba(),
foreground: self.theme.foreground.to_rgba(),
accent: self.theme.accent.to_rgba(),
font_size: self.font.size,
font_family: self.font.family.clone(),
scale: 1,
}
}
pub fn scaled_render_settings(&self, scale: Scale) -> RenderSettings {
RenderSettings {
font_size: scale.scale_font(self.font.size),
scale: scale.get(),
..self.render_settings()
}
}
pub fn physical_height(&self, scale: Scale) -> u32 {
scale.to_physical(self.height)
}
pub fn build_dashboard(&self, bounds: Bounds, monitor: Option<&str>) -> Dashboard {
let zones = self.resolved_zones();
let build_zone = |kinds: &[WidgetKind]| -> Vec<Box<dyn Widget>> {
kinds
.iter()
.map(|&kind| {
let style_config = self.widget.get(kind);
let style = style_config.resolve(&self.theme);
match kind {
WidgetKind::Battery => Box::new(
BatteryWidget::new(bounds)
.with_style(style)
.with_format(style_config.format.clone()),
) as Box<dyn Widget>,
WidgetKind::Backlight => Box::new(
BacklightWidget::new(bounds)
.with_style(style)
.with_device(style_config.device.clone())
.with_format(style_config.format.clone())
.with_format_icons(
style_config
.format_icons
.as_ref()
.and_then(FormatIconsConfig::ramp)
.cloned(),
)
.with_scroll_step(style_config.scroll_step),
) as Box<dyn Widget>,
WidgetKind::PowerProfilesDaemon => Box::new(
PowerProfilesWidget::new(bounds)
.with_style(style)
.with_format(style_config.format.clone())
.with_format_icons(
style_config
.format_icons
.as_ref()
.and_then(FormatIconsConfig::named)
.cloned(),
)
.with_tooltip(style_config.tooltip)
.with_tooltip_format(style_config.tooltip_format.clone()),
),
WidgetKind::Updates => Box::new(
UpdatesWidget::new(bounds)
.with_style(style)
.with_format(style_config.format.clone())
.with_on_click(style_config.on_click.clone()),
),
_ => kind.build(bounds, style, monitor, style_config.on_click.clone()),
}
})
.collect()
};
let left = build_zone(&zones.left);
let center = build_zone(&zones.center);
let right = build_zone(&zones.right);
Dashboard::with_zones(left, center, right).with_spacing(self.bar.margin, self.bar.gap)
}
}
#[derive(Debug)]
pub enum ConfigError {
Read {
path: PathBuf,
source: io::Error,
},
Parse {
path: Option<PathBuf>,
source: toml::de::Error,
},
Invalid {
path: Option<PathBuf>,
message: String,
},
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::Read { path, source } => {
write!(f, "failed to read config {}: {source}", path.display())
}
ConfigError::Parse {
path: Some(path),
source,
} => write!(f, "invalid config {}: {source}", path.display()),
ConfigError::Parse { path: None, source } => {
write!(f, "invalid config: {source}")
}
ConfigError::Invalid {
path: Some(path),
message,
} => write!(f, "invalid config {}: {message}", path.display()),
ConfigError::Invalid {
path: None,
message,
} => write!(f, "invalid config: {message}"),
}
}
}
impl std::error::Error for ConfigError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ConfigError::Read { source, .. } => Some(source),
ConfigError::Parse { source, .. } => Some(source),
ConfigError::Invalid { .. } => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::widget::ClickButton;
#[test]
fn default_is_the_documented_baseline() {
let config = Config::default();
assert_eq!(config.height, 32);
assert_eq!(config.bar.margin, 0);
assert_eq!(config.bar.gap, 0);
assert_eq!(config.bar.background, None);
assert_eq!(config.theme.background, Color::rgb(0x18, 0x18, 0x18));
assert_eq!(config.theme.foreground, Color::rgb(0xEA, 0xEA, 0xEA));
assert_eq!(config.theme.accent, Color::rgb(0xEA, 0xEA, 0xEA));
assert_eq!(config.font.family, None);
assert_eq!(config.font.size, 16.0);
assert_eq!(config.bar.modules_left, vec![WidgetKind::Workspaces]);
assert_eq!(config.bar.modules_center, vec![WidgetKind::Title]);
assert_eq!(
config.bar.modules_right,
vec![
WidgetKind::Clock,
WidgetKind::Battery,
WidgetKind::System,
WidgetKind::Network,
WidgetKind::PowerProfilesDaemon,
],
);
}
#[test]
fn empty_document_is_all_defaults() {
assert_eq!(Config::from_toml_str("").unwrap(), Config::default());
}
#[test]
fn the_shipped_example_config_parses_as_the_themed_desktop_loadout() {
let example = include_str!("../config.example.toml");
let parsed = Config::from_toml_str(example).expect("example config parses");
assert_eq!(parsed.height, 38);
assert_eq!(parsed.theme.background, Color::rgb(0x14, 0x09, 0x17));
assert_eq!(parsed.theme.foreground, Color::rgb(0xEA, 0xE2, 0xCF));
assert_eq!(parsed.theme.accent, Color::rgb(0x7D, 0xEC, 0xFF));
assert_eq!(
parsed.bar.background,
Some(Color::rgba(0x00, 0x00, 0x00, 0x00))
);
assert_eq!(
parsed.bar.modules_left,
vec![WidgetKind::Workspaces, WidgetKind::Title]
);
assert!(parsed.bar.modules_center.is_empty());
assert_eq!(
parsed.bar.modules_right,
vec![
WidgetKind::Updates,
WidgetKind::Bluetooth,
WidgetKind::Backlight,
WidgetKind::Volume,
WidgetKind::System,
WidgetKind::Network,
WidgetKind::Tray,
WidgetKind::Notifications,
WidgetKind::Battery,
WidgetKind::PowerProfilesDaemon,
WidgetKind::Clock,
]
);
assert_eq!(
parsed.widget.workspaces.border,
Some(Color::rgb(0x3D, 0x1F, 0x3A))
);
assert_eq!(parsed.widget.workspaces.border_width, Some(1));
assert_eq!(
parsed.widget.updates.format.as_deref(),
Some("{icon} {count}")
);
assert_eq!(parsed.widget.updates.padding, Some(6));
assert_eq!(
parsed.widget.battery.format.as_deref(),
Some("{icon} {percent}%")
);
assert_eq!(
parsed.widget.battery.charging.foreground,
Some(Color::rgb(0x5F, 0xF5, 0xA0))
);
assert_eq!(
parsed.widget.battery.charging.border,
Some(Color::rgb(0x2D, 0xF1, 0x85))
);
}
#[test]
fn partial_document_overrides_only_named_fields() {
let config = Config::from_toml_str(
r##"
height = 40
[bar]
margin = 6
[theme]
accent = "#ff8800"
"##,
)
.unwrap();
assert_eq!(config.height, 40);
assert_eq!(config.bar.margin, 6);
assert_eq!(config.theme.accent, Color::rgb(0xFF, 0x88, 0x00));
assert_eq!(config.bar.gap, 0);
assert_eq!(config.bar.modules_left, vec![WidgetKind::Workspaces]);
assert_eq!(config.bar.modules_center, vec![WidgetKind::Title]);
assert_eq!(config.theme.background, Color::rgb(0x18, 0x18, 0x18));
}
#[test]
fn full_document_parses_every_section() {
let config = Config::from_toml_str(
r##"
height = 28
[bar]
margin = 4
gap = 2
background = "#101010cc"
modules-left = ["clock"]
modules-center = []
modules-right = ["workspaces"]
[theme]
background = "#000000"
foreground = "#ffffff"
accent = "#00ff00"
[font]
family = "JetBrains Mono"
size = 14.0
[widget.clock]
background = "#222222"
border = "#445566"
border-width = 2
radius = 10
icon = "none"
"##,
)
.unwrap();
assert_eq!(config.height, 28);
assert_eq!(config.bar.margin, 4);
assert_eq!(config.bar.gap, 2);
assert_eq!(
config.bar.background,
Some(Color::rgba(0x10, 0x10, 0x10, 0xCC))
);
assert_eq!(config.theme.background, Color::rgb(0, 0, 0));
assert_eq!(config.theme.foreground, Color::rgb(0xFF, 0xFF, 0xFF));
assert_eq!(config.theme.accent, Color::rgb(0, 0xFF, 0));
assert_eq!(config.font.family.as_deref(), Some("JetBrains Mono"));
assert_eq!(config.font.size, 14.0);
assert_eq!(config.bar.modules_left, vec![WidgetKind::Clock]);
assert!(config.bar.modules_center.is_empty());
assert_eq!(config.bar.modules_right, vec![WidgetKind::Workspaces]);
assert_eq!(
config.widget.clock.background,
Some(Color::rgb(0x22, 0x22, 0x22))
);
assert_eq!(config.widget.clock.radius, Some(10));
assert_eq!(
config.widget.clock.border,
Some(Color::rgb(0x44, 0x55, 0x66))
);
assert_eq!(config.widget.clock.border_width, Some(2));
assert_eq!(config.widget.clock.icon.as_deref(), Some("none"));
}
#[test]
fn color_accepts_hash_and_bare_hex() {
assert_eq!(
Color::parse_hex("#1a2b3c").unwrap(),
Color::rgb(0x1A, 0x2B, 0x3C)
);
assert_eq!(
Color::parse_hex("1a2b3c").unwrap(),
Color::rgb(0x1A, 0x2B, 0x3C)
);
assert_eq!(Color::parse_hex("#1a2b3c").unwrap().a, 0xFF);
assert_eq!(
Color::parse_hex("#1a2b3c80").unwrap(),
Color::rgba(0x1A, 0x2B, 0x3C, 0x80)
);
assert_eq!(
Color::parse_hex("00ff0040").unwrap(),
Color::rgba(0x00, 0xFF, 0x00, 0x40)
);
}
#[test]
fn color_rejects_malformed_hex_with_a_clear_message() {
for bad in ["#fff", "#12345g", "not-a-color", ""] {
let err = Color::parse_hex(bad).unwrap_err();
assert!(err.contains("expected"), "unhelpful message: {err}");
}
}
#[test]
fn invalid_color_in_document_is_an_error_not_a_default() {
let err = Config::from_toml_str(
r##"
[theme]
foreground = "#xyzxyz"
"##,
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("invalid config"), "message: {msg}");
}
#[test]
fn tray_is_an_opt_in_widget_name() {
let bar = Bar::default();
let in_default_zones = bar
.modules_left
.iter()
.chain(&bar.modules_center)
.chain(&bar.modules_right)
.any(|&kind| kind == WidgetKind::Tray);
assert!(!in_default_zones);
let config = Config::from_toml_str(
r#"
[bar]
modules-right = ["tray", "clock"]
"#,
)
.unwrap();
assert_eq!(
config.bar.modules_right,
vec![WidgetKind::Tray, WidgetKind::Clock]
);
let _ = WidgetKind::Tray.build(
Bounds::new(0, 0, 64, 32),
WidgetStyle::default(),
None,
None,
);
}
#[test]
fn updates_is_an_opt_in_widget_name() {
let config = Config::from_toml_str(
r#"
[bar]
modules-right = ["updates", "clock"]
[widget.updates]
format = "{count} {icon}"
on-click = "/usr/local/bin/update-system"
"#,
)
.unwrap();
assert_eq!(
config.bar.modules_right,
vec![WidgetKind::Updates, WidgetKind::Clock]
);
assert_eq!(
config.widget.updates.format.as_deref(),
Some("{count} {icon}")
);
assert_eq!(
config.widget.updates.on_click.as_deref(),
Some(std::path::Path::new("/usr/local/bin/update-system"))
);
}
#[test]
fn uses_widget_checks_global_and_monitor_zones() {
let global = Config::from_toml_str(
r#"
[bar]
modules-right = ["updates"]
"#,
)
.unwrap();
assert!(global.uses_widget(WidgetKind::Updates));
let monitor = Config::from_toml_str(
r#"
[[monitor]]
name = "eDP-1"
[monitor.bar]
modules-right = ["updates"]
"#,
)
.unwrap();
assert!(monitor.uses_widget(WidgetKind::Updates));
assert!(!Config::default().uses_widget(WidgetKind::Updates));
}
#[test]
fn unknown_widget_name_is_an_error() {
let err = Config::from_toml_str(
r#"
[bar]
modules-left = ["clock", "weather"]
"#,
)
.unwrap_err();
assert!(err.to_string().contains("invalid config"));
}
#[test]
fn bluetooth_is_an_opt_in_widget_name_and_builds() {
let bar = Bar::default();
let in_default_zones = bar
.modules_left
.iter()
.chain(&bar.modules_center)
.chain(&bar.modules_right)
.any(|&kind| kind == WidgetKind::Bluetooth);
assert!(!in_default_zones);
let config = Config::from_toml_str(
r#"
[bar]
modules-right = ["bluetooth", "clock"]
[widget.bluetooth]
on-click = "/usr/bin/blueman-manager"
"#,
)
.unwrap();
assert_eq!(
config.bar.modules_right,
vec![WidgetKind::Bluetooth, WidgetKind::Clock]
);
assert_eq!(
config.widget.bluetooth.on_click.as_deref(),
Some(std::path::Path::new("/usr/bin/blueman-manager"))
);
let widget = WidgetKind::Bluetooth.build(
Bounds::new(0, 0, 64, 32),
WidgetStyle::default(),
None,
config.widget.bluetooth.on_click.clone(),
);
let click = widget.on_click(10, 10, ClickButton::Left);
assert_eq!(
click,
Some(crate::widget::Command::RunProgram(
std::path::PathBuf::from("/usr/bin/blueman-manager")
))
);
}
#[test]
fn bluetooth_with_no_on_click_is_display_only() {
let widget = WidgetKind::Bluetooth.build(
Bounds::new(0, 0, 64, 32),
WidgetStyle::default(),
None,
None,
);
assert_eq!(widget.on_click(10, 10, ClickButton::Left), None);
}
#[test]
fn bluetooth_on_click_folds_onto_a_monitor_override() {
let config = Config::from_toml_str(
r#"
[widget.bluetooth]
on-click = "/global/launcher.sh"
[[monitor]]
name = "DP-1"
[monitor.widget.bluetooth]
on-click = "/per-monitor/launcher.sh"
"#,
)
.unwrap();
assert_eq!(
config.widget.bluetooth.on_click.as_deref(),
Some(std::path::Path::new("/global/launcher.sh"))
);
let resolved = config.resolve_for_output(Some("DP-1"));
assert_eq!(
resolved.widget.bluetooth.on_click.as_deref(),
Some(std::path::Path::new("/per-monitor/launcher.sh"))
);
let other = config.resolve_for_output(Some("HDMI-A-1"));
assert_eq!(
other.widget.bluetooth.on_click.as_deref(),
Some(std::path::Path::new("/global/launcher.sh"))
);
}
#[test]
fn volume_is_an_opt_in_widget_name_and_builds() {
let bar = Bar::default();
let in_default_zones = bar
.modules_left
.iter()
.chain(&bar.modules_center)
.chain(&bar.modules_right)
.any(|&kind| kind == WidgetKind::Volume);
assert!(!in_default_zones);
let config = Config::from_toml_str(
r#"
[bar]
modules-right = ["volume", "clock"]
[widget.volume]
on-click = "/usr/bin/pavucontrol"
"#,
)
.unwrap();
assert_eq!(
config.bar.modules_right,
vec![WidgetKind::Volume, WidgetKind::Clock]
);
assert_eq!(
config.widget.volume.on_click.as_deref(),
Some(std::path::Path::new("/usr/bin/pavucontrol"))
);
let widget = WidgetKind::Volume.build(
Bounds::new(0, 0, 64, 32),
WidgetStyle::default(),
None,
config.widget.volume.on_click.clone(),
);
let click = widget.on_click(10, 10, ClickButton::Left);
assert_eq!(
click,
Some(crate::widget::Command::RunProgram(
std::path::PathBuf::from("/usr/bin/pavucontrol")
))
);
}
#[test]
fn volume_with_no_on_click_is_display_only() {
let widget = WidgetKind::Volume.build(
Bounds::new(0, 0, 64, 32),
WidgetStyle::default(),
None,
None,
);
assert_eq!(widget.on_click(10, 10, ClickButton::Left), None);
}
#[test]
fn volume_on_click_folds_onto_a_monitor_override() {
let config = Config::from_toml_str(
r#"
[widget.volume]
on-click = "/global/launcher.sh"
[[monitor]]
name = "DP-1"
[monitor.widget.volume]
on-click = "/per-monitor/launcher.sh"
"#,
)
.unwrap();
assert_eq!(
config.widget.volume.on_click.as_deref(),
Some(std::path::Path::new("/global/launcher.sh"))
);
let resolved = config.resolve_for_output(Some("DP-1"));
assert_eq!(
resolved.widget.volume.on_click.as_deref(),
Some(std::path::Path::new("/per-monitor/launcher.sh"))
);
let other = config.resolve_for_output(Some("HDMI-A-1"));
assert_eq!(
other.widget.volume.on_click.as_deref(),
Some(std::path::Path::new("/global/launcher.sh"))
);
}
#[test]
fn notifications_is_an_opt_in_widget_name_and_builds() {
let bar = Bar::default();
let in_default_zones = bar
.modules_left
.iter()
.chain(&bar.modules_center)
.chain(&bar.modules_right)
.any(|&kind| kind == WidgetKind::Notifications);
assert!(!in_default_zones);
let config = Config::from_toml_str(
r#"
[bar]
modules-right = ["notifications", "clock"]
"#,
)
.unwrap();
assert_eq!(
config.bar.modules_right,
vec![WidgetKind::Notifications, WidgetKind::Clock]
);
let widget = WidgetKind::Notifications.build(
Bounds::new(0, 0, 64, 32),
WidgetStyle::default(),
None,
None,
);
assert_eq!(widget.on_click(10, 10, ClickButton::Left), None);
}
#[test]
fn notifications_style_table_resolves_and_folds_per_monitor() {
let config = Config::from_toml_str(
r##"
[widget.notifications]
foreground = "#111111"
[widget.notifications.attention]
background = "#ff0000"
[[monitor]]
name = "DP-1"
[monitor.widget.notifications]
foreground = "#222222"
"##,
)
.unwrap();
let resolved = config.resolve_for_output(Some("DP-1"));
let notifications = &resolved.widget.notifications;
assert_eq!(notifications.foreground, Some(Color::rgb(0x22, 0x22, 0x22)));
assert_eq!(
notifications.attention.background,
Some(Color::rgb(0xFF, 0x00, 0x00))
);
}
#[test]
fn title_is_a_known_widget_name_and_builds() {
let config = Config::from_toml_str(
r#"
[bar]
modules-center = ["title"]
"#,
)
.unwrap();
assert_eq!(config.bar.modules_center, vec![WidgetKind::Title]);
let _ = WidgetKind::Title.build(
Bounds::new(0, 0, 64, 32),
WidgetStyle::default(),
None,
None,
);
}
#[test]
fn unknown_key_is_rejected_rather_than_ignored() {
let err = Config::from_toml_str("heigth = 40").unwrap_err();
assert!(err.to_string().contains("invalid config"));
}
#[test]
fn resolved_zones_dedupe_across_zones_first_position_wins() {
let config = Config::from_toml_str(
r#"
[bar]
modules-left = ["clock", "battery", "clock"]
modules-center = ["battery", "workspaces"]
modules-right = ["clock", "network"]
"#,
)
.unwrap();
let zones = config.resolved_zones();
assert_eq!(zones.left, vec![WidgetKind::Clock, WidgetKind::Battery]);
assert_eq!(zones.center, vec![WidgetKind::Workspaces]);
assert_eq!(zones.right, vec![WidgetKind::Network]);
}
#[test]
fn resolved_zones_on_empty_zones_are_empty() {
let config = Config::from_toml_str(
r#"
[bar]
modules-left = []
modules-center = []
modules-right = []
"#,
)
.unwrap();
let zones = config.resolved_zones();
assert!(zones.left.is_empty());
assert!(zones.center.is_empty());
assert!(zones.right.is_empty());
}
#[test]
fn load_from_missing_path_is_defaults_not_an_error() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("does-not-exist.toml");
assert_eq!(Config::load_from_path(missing).unwrap(), Config::default());
}
#[test]
fn load_from_path_reads_and_parses_a_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, "height = 48\n").unwrap();
let config = Config::load_from_path(&path).unwrap();
assert_eq!(config.height, 48);
}
#[test]
fn physical_height_scales_the_logical_height() {
let config = Config::from_toml_str("height = 30").unwrap();
assert_eq!(config.physical_height(Scale::ONE), 30);
assert_eq!(config.physical_height(Scale::new(2)), 60);
assert_eq!(config.physical_height(Scale::new(3)), 90);
}
#[test]
fn scaled_render_settings_scale_only_the_font() {
let config = Config::from_toml_str(
r##"
[theme]
foreground = "#102030"
[font]
family = "JetBrains Mono"
size = 16.0
"##,
)
.unwrap();
let scaled = config.scaled_render_settings(Scale::new(2));
assert_eq!(scaled.font_size, 32.0);
assert_eq!(scaled.scale, 2);
assert_eq!(scaled.foreground, (0x10, 0x20, 0x30, 0xFF));
assert_eq!(scaled.font_family.as_deref(), Some("JetBrains Mono"));
}
#[test]
fn scaled_render_settings_at_scale_one_equal_the_logical_settings() {
let config = Config::from_toml_str("[font]\nsize = 18.0").unwrap();
assert_eq!(
config.scaled_render_settings(Scale::ONE),
config.render_settings()
);
}
#[test]
fn no_monitor_overrides_resolves_to_the_base_config() {
let config = Config::from_toml_str(
r#"
height = 30
[bar]
modules-left = ["clock"]
"#,
)
.unwrap();
let resolved = config.resolve_for_output(Some("DP-1"));
assert_eq!(resolved.height, 30);
assert_eq!(resolved.bar.modules_left, vec![WidgetKind::Clock]);
assert!(resolved.monitors.is_empty());
}
#[test]
fn an_output_with_no_name_resolves_to_the_base_config() {
let config = Config::from_toml_str(
r#"
height = 30
[[monitor]]
name = "DP-1"
height = 40
"#,
)
.unwrap();
assert_eq!(config.resolve_for_output(None).height, 30);
}
#[test]
fn a_matching_monitor_overrides_only_its_named_fields() {
let config = Config::from_toml_str(
r##"
height = 32
[theme]
background = "#111111"
foreground = "#eeeeee"
[[monitor]]
name = "HDMI-A-1"
height = 28
[monitor.theme]
background = "#000000"
"##,
)
.unwrap();
let resolved = config.resolve_for_output(Some("HDMI-A-1"));
assert_eq!(resolved.height, 28);
assert_eq!(resolved.theme.background, Color::rgb(0, 0, 0));
assert_eq!(resolved.theme.foreground, Color::rgb(0xEE, 0xEE, 0xEE));
assert_eq!(resolved.bar.modules_left, vec![WidgetKind::Workspaces]);
}
#[test]
fn a_non_matching_output_name_resolves_to_the_base_config() {
let config = Config::from_toml_str(
r#"
height = 32
[[monitor]]
name = "DP-1"
height = 50
"#,
)
.unwrap();
assert_eq!(config.resolve_for_output(Some("eDP-1")).height, 32);
}
#[test]
fn monitor_font_overrides_apply() {
let config = Config::from_toml_str(
r##"
[font]
size = 16.0
[[monitor]]
name = "DP-2"
[monitor.font]
size = 20.0
"##,
)
.unwrap();
let resolved = config.resolve_for_output(Some("DP-2"));
assert_eq!(resolved.font.size, 20.0);
assert_eq!(resolved.font.family, None);
}
#[test]
fn monitor_zone_override_replaces_only_the_named_zone() {
let config = Config::from_toml_str(
r#"
[bar]
modules-left = ["workspaces"]
modules-center = ["clock"]
modules-right = ["battery"]
[[monitor]]
name = "DP-1"
[monitor.bar]
modules-center = ["system", "network"]
"#,
)
.unwrap();
let resolved = config.resolve_for_output(Some("DP-1"));
assert_eq!(
resolved.bar.modules_center,
vec![WidgetKind::System, WidgetKind::Network]
);
assert_eq!(resolved.bar.modules_left, vec![WidgetKind::Workspaces]);
assert_eq!(resolved.bar.modules_right, vec![WidgetKind::Battery]);
assert_eq!(
resolved.resolved_zones().center,
vec![WidgetKind::System, WidgetKind::Network]
);
}
#[test]
fn monitor_widget_override_merges_per_field_onto_the_global_style() {
let config = Config::from_toml_str(
r##"
[widget.battery]
foreground = "#eeeeee"
border = "#445566"
radius = 6
[[monitor]]
name = "DP-1"
[monitor.widget.battery]
background = "#bf616aff"
border-width = 2
"##,
)
.unwrap();
let resolved = config.resolve_for_output(Some("DP-1"));
let battery = &resolved.widget.battery;
assert_eq!(
battery.background,
Some(Color::rgba(0xBF, 0x61, 0x6A, 0xFF))
);
assert_eq!(battery.foreground, Some(Color::rgb(0xEE, 0xEE, 0xEE)));
assert_eq!(battery.border, Some(Color::rgb(0x44, 0x55, 0x66)));
assert_eq!(battery.border_width, Some(2));
assert_eq!(battery.radius, Some(6));
assert_eq!(resolved.widget.clock, WidgetStyleConfig::default());
}
#[test]
fn title_widget_style_folds_onto_per_monitor_override() {
let config = Config::from_toml_str(
r##"
[widget.title]
foreground = "#eaeaea"
radius = 8
[[monitor]]
name = "DP-1"
[monitor.widget.title]
background = "#2e3440"
"##,
)
.unwrap();
let resolved = config.resolve_for_output(Some("DP-1"));
let title = &resolved.widget.title;
assert_eq!(title.background, Some(Color::rgb(0x2E, 0x34, 0x40)));
assert_eq!(title.foreground, Some(Color::rgb(0xEA, 0xEA, 0xEA)));
assert_eq!(title.radius, Some(8));
assert_eq!(resolved.widget.clock, WidgetStyleConfig::default());
}
#[test]
fn monitor_bar_background_and_spacing_override_apply() {
let config = Config::from_toml_str(
r##"
[bar]
margin = 4
gap = 4
background = "#181818"
[[monitor]]
name = "DP-1"
[monitor.bar]
background = "#00000080"
margin = 8
"##,
)
.unwrap();
let resolved = config.resolve_for_output(Some("DP-1"));
assert_eq!(resolved.bar.margin, 8);
assert_eq!(resolved.bar.background, Some(Color::rgba(0, 0, 0, 0x80)));
assert_eq!(resolved.bar.gap, 4);
assert_eq!(resolved.render_settings().background, (0, 0, 0, 0x80));
}
#[test]
fn duplicate_monitor_entries_resolve_to_the_first_match() {
let config = Config::from_toml_str(
r#"
[[monitor]]
name = "DP-1"
height = 40
[[monitor]]
name = "DP-1"
height = 99
"#,
)
.unwrap();
assert_eq!(config.resolve_for_output(Some("DP-1")).height, 40);
}
#[test]
fn load_from_path_reports_the_file_on_parse_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("broken.toml");
fs::write(&path, "height = \"tall\"\n").unwrap();
let err = Config::load_from_path(&path).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("broken.toml"), "message: {msg}");
}
#[test]
fn zero_height_is_rejected() {
let err = Config::from_toml_str("height = 0").unwrap_err();
assert!(
matches!(err, ConfigError::Invalid { .. }),
"wrong variant: {err:?}"
);
assert!(err.to_string().contains("height"), "message: {err}");
}
#[test]
fn absurdly_tall_bar_is_rejected() {
let err = Config::from_toml_str("height = 100000").unwrap_err();
assert!(
matches!(err, ConfigError::Invalid { .. }),
"wrong variant: {err:?}"
);
}
#[test]
fn zero_font_size_is_rejected() {
let err = Config::from_toml_str("[font]\nsize = 0.0").unwrap_err();
assert!(
matches!(err, ConfigError::Invalid { .. }),
"wrong variant: {err:?}"
);
assert!(err.to_string().contains("font.size"), "message: {err}");
}
#[test]
fn non_finite_font_size_is_rejected() {
for doc in ["[font]\nsize = nan", "[font]\nsize = inf"] {
let err = Config::from_toml_str(doc).unwrap_err();
assert!(
matches!(err, ConfigError::Invalid { .. }),
"{doc:?} -> {err:?}"
);
}
}
#[test]
fn negative_font_size_is_rejected() {
let err = Config::from_toml_str("[font]\nsize = -4.0").unwrap_err();
assert!(
matches!(err, ConfigError::Invalid { .. }),
"wrong variant: {err:?}"
);
}
#[test]
fn nameless_monitor_override_is_rejected() {
let err = Config::from_toml_str("[[monitor]]\nheight = 30").unwrap_err();
assert!(
matches!(err, ConfigError::Invalid { .. }),
"wrong variant: {err:?}"
);
assert!(err.to_string().contains("name"), "message: {err}");
}
#[test]
fn out_of_range_monitor_override_is_rejected() {
let err = Config::from_toml_str("[[monitor]]\nname = \"DP-1\"\nheight = 0").unwrap_err();
assert!(
matches!(err, ConfigError::Invalid { .. }),
"wrong variant: {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("DP-1") && msg.contains("height"),
"message: {msg}"
);
}
#[test]
fn boundary_values_are_accepted() {
let config = Config::from_toml_str("height = 4096\n[font]\nsize = 1.0").unwrap();
assert_eq!(config.height, 4096);
assert_eq!(config.font.size, 1.0);
}
#[test]
fn backlight_configuration_parses_and_merges_per_monitor() {
let config = Config::from_toml_str(
r#"
[bar]
modules-right = ["backlight"]
[widget.backlight]
device = "intel_backlight"
format = "{icon} {percent}%"
format-icons = ["low", "high"]
scroll-step = 1.5
[[monitor]]
name = "eDP-1"
[monitor.widget.backlight]
scroll-step = 2.0
"#,
)
.unwrap();
assert_eq!(config.bar.modules_right, vec![WidgetKind::Backlight]);
assert_eq!(
config.widget.backlight.device.as_deref(),
Some("intel_backlight")
);
assert_eq!(
config
.widget
.backlight
.format_icons
.as_ref()
.and_then(FormatIconsConfig::ramp)
.map(Vec::as_slice),
Some(&["low".to_string(), "high".to_string()][..])
);
let resolved = config.resolve_for_output(Some("eDP-1"));
assert_eq!(resolved.widget.backlight.scroll_step, Some(2.0));
assert_eq!(
resolved.widget.backlight.device.as_deref(),
Some("intel_backlight")
);
}
#[test]
fn invalid_backlight_configuration_is_rejected() {
for doc in [
"[widget.backlight]\nformat = \"{unknown}\"",
"[widget.backlight]\nformat-icons = []",
"[widget.backlight]\nscroll-step = 0.0",
"[widget.backlight]\ndevice = \"../panel\"",
] {
let error = Config::from_toml_str(doc).unwrap_err();
assert!(
matches!(error, ConfigError::Invalid { .. }),
"{doc}: {error:?}"
);
assert!(error.to_string().contains("widget.backlight"));
}
}
#[test]
fn invalid_battery_format_is_rejected() {
let error = Config::from_toml_str("[widget.battery]\nformat = \"{watts}\"")
.unwrap_err()
.to_string();
assert!(error.contains("widget.battery.format"), "message: {error}");
assert!(error.contains("{watts}"), "message: {error}");
}
#[test]
fn invalid_updates_format_is_rejected() {
let error = Config::from_toml_str("[widget.updates]\nformat = \"{percent}\"")
.unwrap_err()
.to_string();
assert!(error.contains("widget.updates.format"), "message: {error}");
assert!(error.contains("{percent}"), "message: {error}");
}
#[test]
fn power_profiles_configuration_parses_and_merges_per_monitor() {
let config = Config::from_toml_str(
r#"
[widget.power-profiles-daemon]
format = "{icon} {profile}"
tooltip = true
tooltip-format = "Power profile: {profile}\nDriver: {driver}"
[widget.power-profiles-daemon.format-icons]
default = "sun"
balanced = "balance"
performance = "bolt"
power-saver = "leaf"
[[monitor]]
name = "eDP-1"
[monitor.widget.power-profiles-daemon]
tooltip = false
"#,
)
.unwrap();
let widget = &config.widget.power_profiles_daemon;
assert_eq!(widget.format.as_deref(), Some("{icon} {profile}"));
assert_eq!(widget.tooltip, Some(true));
let icons = widget
.format_icons
.as_ref()
.and_then(FormatIconsConfig::named)
.expect("named icons");
assert_eq!(icons.get("performance").map(String::as_str), Some("bolt"));
let resolved = config.resolve_for_output(Some("eDP-1"));
assert_eq!(resolved.widget.power_profiles_daemon.tooltip, Some(false));
assert_eq!(
resolved.widget.power_profiles_daemon.format.as_deref(),
Some("{icon} {profile}")
);
}
#[test]
fn invalid_power_profiles_configuration_is_rejected() {
for doc in [
"[widget.power-profiles-daemon]\nformat = \"{percent}\"",
"[widget.power-profiles-daemon]\ntooltip-format = \"{unknown}\"",
"[widget.power-profiles-daemon]\nformat-icons = []",
] {
let error = Config::from_toml_str(doc).unwrap_err().to_string();
assert!(error.contains("power-profiles-daemon"), "message: {error}");
}
}
#[test]
fn an_invalid_file_names_the_file_and_the_problem() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad.toml");
fs::write(&path, "height = 0\n").unwrap();
let err = Config::load_from_path(&path).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("bad.toml"), "message: {msg}");
assert!(msg.contains("height"), "message: {msg}");
}
}