use crate::ui::{BarPosition, Component};
#[cfg_attr(feature = "config", derive(serde::Deserialize))]
#[cfg_attr(feature = "doc", derive(serde::Serialize))]
#[cfg_attr(feature = "config", serde(deny_unknown_fields))]
pub struct Config {
#[cfg_attr(feature = "config", serde(default))]
pub style: Style,
#[cfg_attr(feature = "config", serde(default))]
pub behaviour: Behaviour,
#[cfg_attr(feature = "config", serde(default))]
pub security: Security,
}
#[cfg_attr(feature = "config", derive(serde::Deserialize))]
#[cfg_attr(feature = "doc", derive(serde::Serialize))]
#[cfg_attr(feature = "config", serde(default, deny_unknown_fields))]
pub struct Behaviour {
#[cfg_attr(feature = "config", serde(default = "default_components"))]
pub components: Vec<Component>,
#[cfg_attr(feature = "config", serde(default = "default_true"))]
pub should_show_empty_workspace: bool,
#[cfg_attr(feature = "config", serde(default = "default_3"))]
pub on_scroll_brightness_step: u8,
#[cfg_attr(feature = "config", serde(default = "default_3"))]
pub on_scroll_volume_step: u8,
#[cfg_attr(feature = "config", serde(default = "default_10"))]
pub workspace_count: u8,
}
#[cfg_attr(feature = "config", derive(serde::Deserialize))]
#[cfg_attr(feature = "doc", derive(serde::Serialize))]
#[cfg_attr(feature = "config", serde(default, deny_unknown_fields))]
pub struct Style {
#[cfg_attr(feature = "config", serde(default))]
pub position: BarPosition,
#[cfg_attr(feature = "config", serde(default = "default_bar_size_px"))]
pub bar_size_px: usize,
}
#[cfg_attr(feature = "config", derive(serde::Deserialize))]
#[cfg_attr(feature = "doc", derive(serde::Serialize))]
#[cfg_attr(feature = "config", serde(default, deny_unknown_fields))]
pub struct Security {
pub should_allow_workspace_label: bool,
}
impl Default for Style {
fn default() -> Self {
Self {
position: BarPosition::default(),
bar_size_px: default_bar_size_px(),
}
}
}
impl Default for Behaviour {
fn default() -> Self {
Self {
should_show_empty_workspace: default_true(),
components: default_components(),
on_scroll_volume_step: default_3(),
on_scroll_brightness_step: default_3(),
workspace_count: default_10(),
}
}
}
#[allow(clippy::derivable_impls, reason = "Make declaration explicit")]
impl Default for Security {
fn default() -> Self {
Self {
should_allow_workspace_label: false,
}
}
}
impl Config {
pub fn hardcoded() -> Self {
Config {
style: Style::default(),
security: Security::default(),
behaviour: Behaviour::default(),
}
}
}
pub(crate) static CONFIG: std::sync::LazyLock<Config> = std::sync::LazyLock::new(load);
#[cfg(feature = "config")]
fn load() -> Config {
load_from(config_path().as_deref())
}
#[cfg(feature = "config")]
fn load_from(path: Option<&std::path::Path>) -> Config {
let Some(path) = path else {
crate::log::warn(
"config",
"could not determine the configuration directory, using defaults",
);
return Config::hardcoded();
};
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Config::hardcoded(),
Err(err) => {
crate::log::warn(
"config",
&format!("cannot read {}: {err}, using defaults", path.display()),
);
return Config::hardcoded();
},
};
match toml::from_str(&text) {
Ok(config) => config,
Err(err) => {
crate::log::error("config", &format!("{}: {err}", path.display()));
std::process::exit(1);
},
}
}
#[cfg(not(feature = "config"))]
fn load() -> Config {
Config::hardcoded()
}
#[cfg(feature = "cli")]
use std::{path::Path, sync::OnceLock};
#[cfg(feature = "cli")]
pub static CLI_CONFIG_PATH: OnceLock<Box<Path>> = OnceLock::new();
#[cfg(feature = "config")]
fn config_path() -> Option<Box<std::path::Path>> {
#[cfg(feature = "cli")]
{
CLI_CONFIG_PATH.clone().into_inner().or_else(|| {
Some(
directories::BaseDirs::new()?
.config_dir()
.join("tkbar")
.join("config.toml")
.into_boxed_path(),
)
})
}
#[cfg(not(feature = "cli"))]
{
Some(
directories::BaseDirs::new()?
.config_dir()
.join("tkbar")
.join("config.toml")
.into_boxed_path(),
)
}
}
pub(crate) const fn default_bar_size_px() -> usize {
72
}
pub(crate) const fn default_true() -> bool {
true
}
pub(crate) const fn default_3() -> u8 {
3
}
pub(crate) const fn default_10() -> u8 {
10
}
fn default_components() -> Vec<Component> {
vec![
Component::Logo('󱄅'),
#[cfg(any(feature = "niri", feature = "hyprland", feature = "sway"))]
Component::Workspaces,
Component::Spacer,
#[cfg(feature = "battery")]
Component::Battery,
#[cfg(feature = "wifi")]
Component::Wifi,
#[cfg(feature = "brightness")]
Component::Brightness,
#[cfg(feature = "volume")]
Component::Volume,
#[cfg(feature = "clock")]
Component::Clock,
]
}
#[cfg(all(test, feature = "config"))]
mod tests {
use super::*;
#[test]
fn parses_full_config() {
let config: Config = toml::from_str(include_str!("../docs/config.toml")).unwrap();
assert_eq!(config.style.position, BarPosition::Left);
assert_eq!(config.style.bar_size_px, 72);
assert_eq!(config.behaviour.components.len(), 8);
assert!(matches!(
config.behaviour.components.first(),
Some(Component::Logo('󱄅'))
));
}
#[test]
fn empty_file_falls_back_to_field_defaults() {
let config: Config = toml::from_str("").unwrap();
assert_eq!(config.style.position, BarPosition::Left);
assert_eq!(config.style.bar_size_px, default_bar_size_px());
assert_eq!(
config.behaviour.components.len(),
default_components().len()
);
}
#[test]
fn rejects_unknown_fields() {
assert!(toml::from_str::<Config>("bogus = 1").is_err());
}
#[test]
fn partial_behaviour_uses_field_defaults() {
let config: Config = toml::from_str("[behaviour]\ncomponents = [\"clock\"]\n").unwrap();
assert_eq!(config.behaviour.components.len(), 1);
assert!(config.behaviour.should_show_empty_workspace);
assert_eq!(config.behaviour.workspace_count, default_10());
assert_eq!(config.behaviour.on_scroll_volume_step, default_3());
}
#[test]
fn behaviour_without_components_uses_default_components() {
let config: Config = toml::from_str("[behaviour]\nworkspace_count = 5\n").unwrap();
assert_eq!(config.behaviour.workspace_count, 5);
assert_eq!(
config.behaviour.components.len(),
default_components().len()
);
}
#[test]
fn rejects_unknown_field_in_style() {
assert!(toml::from_str::<Config>("[style]\nbar_size = 40\n").is_err());
}
#[test]
fn rejects_unknown_field_in_behaviour() {
let toml = "
[behaviour]
components = []
should_show_empty_workspace = true
on_scroll_brightness_step = 3
on_scroll_volume_step = 3
workspace_count = 10
workspace_cout = 4
";
assert!(toml::from_str::<Config>(toml).is_err());
}
#[test]
fn components_and_position_are_validated_in_their_section() {
let err = toml::from_str::<Config>("[behaviour]\ncomponents = [\"coffee\"]\n")
.err()
.unwrap()
.to_string();
assert!(err.contains("unknown variant"), "{err}");
let err = toml::from_str::<Config>("[style]\nposition = \"diagonal\"\n")
.err()
.unwrap()
.to_string();
assert!(err.contains("unknown variant"), "{err}");
}
#[test]
fn missing_file_falls_back_to_hardcoded() {
let config = load_from(Some(std::path::Path::new("/nonexistent/tkbar.toml")));
assert_eq!(config.style.position, BarPosition::Left);
assert_eq!(config.style.bar_size_px, default_bar_size_px());
assert_eq!(
config.behaviour.components.len(),
default_components().len()
);
}
#[test]
fn loads_partial_config_from_file() {
let dir = std::env::temp_dir().join(format!("tkbar-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.toml");
std::fs::write(&path, "[style]\nbar_size_px = 42\n").unwrap();
let config = load_from(Some(&path));
assert_eq!(config.style.bar_size_px, 42);
assert_eq!(
config.behaviour.components.len(),
default_components().len()
);
std::fs::remove_dir_all(dir).unwrap();
}
}