From 0e46bfb256a11bf6afffae3c6448d4c540152b87 Mon Sep 17 00:00:00 2001 From: Simone Camito Date: Mon, 5 Oct 2026 19:14:01 +0200 Subject: [PATCH 1/3] feat(config): add style options for borders, module islands and groups Add the configuration for bar and module borders, the default module island style, the group island style and per-module overrides (grouping, spacing, text color, font size). Colors accept a hex value or a palette reference, rejected at parse time when unknown. Nothing is rendered yet: omitted keys keep today's look. Refs #1022 --- src/config.rs | 548 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 516 insertions(+), 32 deletions(-) diff --git a/src/config.rs b/src/config.rs index 90a26b19..1caa7d9f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -111,8 +111,13 @@ impl Config { if let Some(ref mut updates) = self.updates { updates.validate(); } - self.appearance.bar.margin.validate(); - self.appearance.bar.padding.validate(); + self.appearance.bar.margin.validate("appearance.bar.margin"); + self.appearance + .bar + .padding + .validate("appearance.bar.padding"); + self.appearance.bar.border.validate("appearance.bar.border"); + self.appearance.modules.validate(&self.custom_modules); self.system_info.validate(); self.tempo.validate(); self.settings.validate(); @@ -1005,7 +1010,8 @@ impl BackgroundAppearanceColor { } } -#[derive(Clone, Copy, Debug)] +#[derive(Deserialize, Clone, Copy, Debug, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] pub enum BackgroundLevel { Weakest, Weaker, @@ -1062,12 +1068,16 @@ impl Default for MarginSize { } } +/// Layer-shell accepts negative margins, which silently push the bar off +/// screen; a negative or non-finite length here is always a mistake. +fn is_valid_length(length: f32) -> bool { + length.is_finite() && length >= 0.0 +} + impl MarginSize { - /// Layer-shell accepts negative margins, which silently push the bar off - /// screen; a negative or non-finite length here is always a mistake. fn validate(&mut self, field: &str, edge: &str) { if let MarginSize::Pixels(pixels) = *self - && (!pixels.is_finite() || pixels < 0.0) + && !is_valid_length(pixels) { warn!("{field} {edge} ({pixels}) is not a valid length, using 0"); *self = MarginSize::Pixels(0.0); @@ -1163,11 +1173,11 @@ impl<'de> Deserialize<'de> for BarMargin { } impl BarMargin { - fn validate(&mut self) { - self.top.validate("appearance.bar.margin", "top"); - self.right.validate("appearance.bar.margin", "right"); - self.bottom.validate("appearance.bar.margin", "bottom"); - self.left.validate("appearance.bar.margin", "left"); + fn validate(&mut self, field: &str) { + self.top.validate(field, "top"); + self.right.validate(field, "right"); + self.bottom.validate(field, "bottom"); + self.left.validate(field, "left"); } } @@ -1209,11 +1219,256 @@ impl<'de> Deserialize<'de> for BarPadding { } impl BarPadding { - fn validate(&mut self) { - self.top.validate("appearance.bar.padding", "top"); - self.right.validate("appearance.bar.padding", "right"); - self.bottom.validate("appearance.bar.padding", "bottom"); - self.left.validate("appearance.bar.padding", "left"); + fn validate(&mut self, field: &str) { + self.top.validate(field, "top"); + self.right.validate(field, "right"); + self.bottom.validate(field, "bottom"); + self.left.validate(field, "left"); + } +} + +/// A palette entry, resolved from the surface's theme when drawing. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PaletteColor { + Background(Option), + Primary(Shade), + Success(Shade), + Warning(Shade), + Danger(Shade), + Text, +} + +#[derive(Deserialize, Clone, Copy, Debug, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Shade { + #[serde(skip)] + Base, + Weak, + Strong, +} + +impl PaletteColor { + fn parse(value: &str) -> Result { + fn variant<'de, T: Deserialize<'de>, E: de::Error>(value: &'de str) -> Result { + T::deserialize(de::value::StrDeserializer::new(value)) + } + let shade = |shade: Option<&str>| shade.map_or(Ok(Shade::Base), variant); + + let (name, variant_name) = match value.split_once('.') { + Some((name, variant_name)) => (name, Some(variant_name)), + None => (value, None), + }; + Ok(match (name, variant_name) { + ("background", level) => PaletteColor::Background(level.map(variant).transpose()?), + ("primary", v) => PaletteColor::Primary(shade(v)?), + ("success", v) => PaletteColor::Success(shade(v)?), + ("warning", v) => PaletteColor::Warning(shade(v)?), + ("danger", v) => PaletteColor::Danger(shade(v)?), + ("text", None) => PaletteColor::Text, + _ => return Err(E::custom(format!("unknown palette color `{value}`"))), + }) + } +} + +/// A hex color (`#RRGGBB`) or a reference to the palette (`primary`, `background.strong`, ...). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ColorRef { + Hex(HexColor), + Palette(PaletteColor), +} + +impl<'de> Deserialize<'de> for ColorRef { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + if value.starts_with('#') { + HexColor::parse(&value) + .map(ColorRef::Hex) + .map_err(de::Error::custom) + } else { + PaletteColor::parse(&value).map(ColorRef::Palette) + } + } +} + +#[derive(Deserialize, Clone, Copy, Debug, PartialEq)] +#[serde(default)] +pub struct Border { + pub width: f32, + pub color: ColorRef, +} + +impl Default for Border { + fn default() -> Self { + Self { + width: 0.0, + color: ColorRef::Palette(PaletteColor::Background(Some(BackgroundLevel::Strong))), + } + } +} + +impl Border { + fn validate(&mut self, field: &str) { + if !is_valid_length(self.width) { + warn!( + "{field}.width ({}) is not a valid length, using 0", + self.width + ); + self.width = 0.0; + } + } +} + +/// How a module's elements are wrapped in islands. +#[derive(Deserialize, Default, Clone, Copy, Debug, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Grouping { + /// One island around all of the module's elements. + #[default] + Combined, + /// One island per element. + Individual, + /// No island. + None, +} + +#[derive(Deserialize, Copy, Clone, PartialEq, Eq, Debug)] +#[serde(rename_all = "lowercase")] +pub enum FontSizeScale { + Xxs, + Xs, + Sm, + Md, + Lg, + Xl, + Xxl, +} + +/// `appearance.modules.default`: the style of every module island. +#[derive(Deserialize, Clone, Copy, Debug, Default, PartialEq)] +pub struct ModuleDefaultStyle { + pub radius: Option, + pub padding: Option, + pub border: Option, + pub background: Option, + pub grouping: Option, +} + +/// `appearance.modules.group`: the style of the islands of module groups. +#[derive(Deserialize, Clone, Copy, Debug, Default, PartialEq)] +pub struct GroupStyle { + pub radius: Option, + pub padding: Option, + pub border: Option, + pub background: Option, + pub spacing: Option, +} + +/// `appearance.modules.`: per-module overrides. +#[derive(Deserialize, Clone, Copy, Debug, Default, PartialEq)] +pub struct ModuleStyle { + pub radius: Option, + pub padding: Option, + pub border: Option, + pub background: Option, + pub grouping: Option, + pub spacing: Option, + pub text_color: Option, + pub font_size: Option, +} + +/// The `[appearance.modules]` table: the reserved `default` and `group` keys, +/// every other key is a module name as used in the layout. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ModulesAppearance { + pub default: ModuleDefaultStyle, + pub group: GroupStyle, + pub overrides: HashMap, +} + +impl<'de> Deserialize<'de> for ModulesAppearance { + fn deserialize>(deserializer: D) -> Result { + struct ModulesAppearanceVisitor; + + impl<'de> Visitor<'de> for ModulesAppearanceVisitor { + type Value = ModulesAppearance; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a table of module styles") + } + + fn visit_map>(self, mut map: M) -> Result { + let mut modules = ModulesAppearance::default(); + while let Some(name) = map.next_key::()? { + match name { + ModuleName::Custom(key) if key == "default" => { + modules.default = map.next_value()? + } + ModuleName::Custom(key) if key == "group" => { + modules.group = map.next_value()? + } + name => { + modules.overrides.insert(name, map.next_value()?); + } + } + } + Ok(modules) + } + } + + deserializer.deserialize_map(ModulesAppearanceVisitor) + } +} + +impl ModulesAppearance { + fn validate(&mut self, custom_modules: &[CustomModuleDef]) { + let default = &mut self.default; + validate_island( + "appearance.modules.default", + &mut default.border, + &mut default.padding, + None, + ); + let group = &mut self.group; + validate_island( + "appearance.modules.group", + &mut group.border, + &mut group.padding, + group.spacing.as_mut(), + ); + for (name, style) in &mut self.overrides { + validate_island( + &format!("appearance.modules.{name}"), + &mut style.border, + &mut style.padding, + style.spacing.as_mut(), + ); + } + + for module in custom_modules { + if matches!(module.name.as_str(), "default" | "group") { + warn!( + "Custom module name `{}` is reserved in [appearance.modules], its style cannot be overridden", + module.name + ); + } + } + } +} + +fn validate_island( + field: &str, + border: &mut Option, + padding: &mut Option, + spacing: Option<&mut MarginSize>, +) { + if let Some(border) = border { + border.validate(&format!("{field}.border")); + } + if let Some(padding) = padding { + padding.validate(&format!("{field}.padding")); + } + if let Some(spacing) = spacing { + spacing.validate(field, "spacing"); } } @@ -1224,6 +1479,7 @@ pub struct BarAppearance { pub radius: BarRadius, pub margin: BarMargin, pub padding: BarPadding, + pub border: Border, } impl Default for BarAppearance { @@ -1239,6 +1495,7 @@ impl Default for BarAppearance { bottom: xxs, left: xxs, }, + border: Border::default(), } } } @@ -1365,6 +1622,7 @@ pub struct Appearance { pub opacity: Opacity, pub bar: BarAppearance, pub menu: MenuAppearance, + pub modules: ModulesAppearance, pub background_color: BackgroundAppearanceColor, pub primary_color: AppearanceColor, pub success_color: AppearanceColor, @@ -1444,6 +1702,7 @@ impl Default for Appearance { opacity: Opacity::default(), bar: BarAppearance::default(), menu: MenuAppearance::default(), + modules: ModulesAppearance::default(), background_color: BackgroundAppearanceColor::Complete { base: HexColor::rgb(26, 27, 38), weakest: None, @@ -1485,7 +1744,7 @@ pub enum Layer { Overlay, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum ModuleName { Updates, Workspaces, @@ -1502,6 +1761,37 @@ pub enum ModuleName { Notifications, } +const BUILTIN_MODULES: [(&str, ModuleName); 12] = [ + ("Updates", ModuleName::Updates), + ("Workspaces", ModuleName::Workspaces), + ("WindowTitle", ModuleName::WindowTitle), + ("SystemInfo", ModuleName::SystemInfo), + ("KeyboardLayout", ModuleName::KeyboardLayout), + ("KeyboardSubmap", ModuleName::KeyboardSubmap), + ("Tray", ModuleName::Tray), + ("Notifications", ModuleName::Notifications), + ("Tempo", ModuleName::Tempo), + ("Privacy", ModuleName::Privacy), + ("Settings", ModuleName::Settings), + ("MediaPlayer", ModuleName::MediaPlayer), +]; + +impl std::fmt::Display for ModuleName { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + let name = match self { + ModuleName::Custom(name) => name, + builtin => { + BUILTIN_MODULES + .iter() + .find(|(_, module)| module == builtin) + .expect("every builtin module has a name") + .0 + } + }; + f.write_str(name) + } +} + impl<'de> Deserialize<'de> for ModuleName { fn deserialize(deserializer: D) -> Result where @@ -1517,21 +1807,13 @@ impl<'de> Deserialize<'de> for ModuleName { where E: serde::de::Error, { - Ok(match value { - "Updates" => ModuleName::Updates, - "Workspaces" => ModuleName::Workspaces, - "WindowTitle" => ModuleName::WindowTitle, - "SystemInfo" => ModuleName::SystemInfo, - "KeyboardLayout" => ModuleName::KeyboardLayout, - "KeyboardSubmap" => ModuleName::KeyboardSubmap, - "Tray" => ModuleName::Tray, - "Notifications" => ModuleName::Notifications, - "Tempo" => ModuleName::Tempo, - "Privacy" => ModuleName::Privacy, - "Settings" => ModuleName::Settings, - "MediaPlayer" => ModuleName::MediaPlayer, - other => ModuleName::Custom(other.to_string()), - }) + Ok(BUILTIN_MODULES + .iter() + .find(|(name, _)| *name == value) + .map_or_else( + || ModuleName::Custom(value.to_string()), + |(_, module)| module.clone(), + )) } } deserializer.deserialize_str(ModuleNameVisitor) @@ -1939,3 +2221,205 @@ pub fn subscription(path: &Path) -> Subscription { }) }) } + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(toml: &str) -> Result { + toml::from_str(toml).map(|mut config: Config| { + config.validate(); + config + }) + } + + fn ignored_fields(toml: &str) -> Vec { + let mut ignored = Vec::new(); + let _: Config = + serde_ignored::deserialize(toml::Deserializer::parse(toml).unwrap(), |path| { + ignored.push(path.to_string()) + }) + .unwrap(); + ignored + } + + const BACKGROUND_STRONG: ColorRef = + ColorRef::Palette(PaletteColor::Background(Some(BackgroundLevel::Strong))); + + #[test] + fn style_defaults_keep_current_look() { + let appearance = parse("").unwrap().appearance; + assert_eq!(appearance.bar.border.width, 0.0); + assert_eq!(appearance.bar.border.color, BACKGROUND_STRONG); + assert_eq!(appearance.modules, ModulesAppearance::default()); + } + + #[test] + fn parses_issue_example() { + let modules = parse( + r##" + [appearance.bar] + border = { width = 1, color = "background.strong" } + + [appearance.modules.default] + radius = "lg" + padding = "none" + border = { width = 1, color = "background.strong" } + background = "background" + grouping = "combined" + + [appearance.modules.group] + radius = "md" + padding = "xxs" + border = { width = 1, color = "primary" } + background = "background.weak" + spacing = "xs" + + [appearance.modules.SystemInfo] + grouping = "individual" + spacing = "xs" + radius = "md" + border = { width = 1, color = "#414868" } + text_color = "primary" + + [appearance.modules.Workspaces] + font_size = "sm" + + [appearance.modules.my_custom_module] + font_size = "lg" + "##, + ) + .unwrap() + .appearance + .modules; + + assert_eq!(modules.default.grouping, Some(Grouping::Combined)); + assert_eq!( + modules.default.background, + Some(ColorRef::Palette(PaletteColor::Background(None))) + ); + assert_eq!( + modules.group.border.unwrap().color, + ColorRef::Palette(PaletteColor::Primary(Shade::Base)) + ); + assert_eq!( + modules.group.spacing, + Some(MarginSize::SpaceSize(SpaceSize::Xs)) + ); + + let system_info = modules.overrides[&ModuleName::SystemInfo]; + assert_eq!(system_info.grouping, Some(Grouping::Individual)); + assert_eq!( + system_info.border.unwrap().color, + ColorRef::Hex(HexColor::rgb(0x41, 0x48, 0x68)) + ); + assert_eq!( + system_info.text_color, + Some(ColorRef::Palette(PaletteColor::Primary(Shade::Base))) + ); + assert_eq!(system_info.font_size, None); + + assert_eq!( + modules.overrides[&ModuleName::Workspaces].font_size, + Some(FontSizeScale::Sm) + ); + assert_eq!( + modules.overrides[&ModuleName::Custom("my_custom_module".into())].font_size, + Some(FontSizeScale::Lg) + ); + } + + #[test] + fn border_fields_default_individually() { + let bar = parse("[appearance.bar]\nborder = { width = 2 }") + .unwrap() + .appearance + .bar; + assert_eq!(bar.border.width, 2.0); + assert_eq!(bar.border.color, BACKGROUND_STRONG); + } + + #[test] + fn negative_border_width_falls_back_to_zero() { + let modules = parse("[appearance.modules.default]\nborder = { width = -1 }") + .unwrap() + .appearance + .modules; + assert_eq!(modules.default.border.unwrap().width, 0.0); + } + + #[test] + fn parses_palette_references() { + use PaletteColor::*; + let cases = [ + ("background", Background(None)), + ( + "background.weakest", + Background(Some(BackgroundLevel::Weakest)), + ), + ( + "background.neutral", + Background(Some(BackgroundLevel::Neutral)), + ), + ( + "background.strongest", + Background(Some(BackgroundLevel::Strongest)), + ), + ("primary.weak", Primary(Shade::Weak)), + ("success", Success(Shade::Base)), + ("warning.strong", Warning(Shade::Strong)), + ("danger.weak", Danger(Shade::Weak)), + ("text", Text), + ]; + for (name, expected) in cases { + assert_eq!( + PaletteColor::parse::(name), + Ok(expected), + "{name}" + ); + } + } + + #[test] + fn rejects_unknown_colors() { + for color in [ + "accent", + "primary.base", + "primary.strongest", + "background.base", + "text.weak", + "#zzzzzz", + ] { + let toml = format!("[appearance.modules.default]\nbackground = \"{color}\""); + assert!(parse(&toml).is_err(), "{color} should be rejected"); + } + } + + #[test] + fn rejects_unknown_grouping_and_font_size() { + assert!(parse("[appearance.modules.default]\ngrouping = \"all\"").is_err()); + assert!(parse("[appearance.modules.Tray]\nfont_size = \"huge\"").is_err()); + } + + #[test] + fn reports_options_outside_their_table() { + let ignored = ignored_fields( + r#" + [appearance.modules.default] + font_size = "sm" + spacing = "xs" + + [appearance.modules.group] + grouping = "none" + "#, + ); + assert_eq!( + ignored, + [ + "appearance.modules.default.font_size", + "appearance.modules.default.spacing", + "appearance.modules.group.grouping", + ] + ); + } +} From ae58accfca06382a87c4c2f291efda025c5f00ca Mon Sep 17 00:00:00 2001 From: Simone Camito Date: Mon, 5 Oct 2026 19:20:03 +0200 Subject: [PATCH 2/3] refactor(config): rename validate_island to validate_style --- src/config.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/config.rs b/src/config.rs index 1caa7d9f..53c1b4e6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1422,21 +1422,21 @@ impl<'de> Deserialize<'de> for ModulesAppearance { impl ModulesAppearance { fn validate(&mut self, custom_modules: &[CustomModuleDef]) { let default = &mut self.default; - validate_island( + validate_style( "appearance.modules.default", &mut default.border, &mut default.padding, None, ); let group = &mut self.group; - validate_island( + validate_style( "appearance.modules.group", &mut group.border, &mut group.padding, group.spacing.as_mut(), ); for (name, style) in &mut self.overrides { - validate_island( + validate_style( &format!("appearance.modules.{name}"), &mut style.border, &mut style.padding, @@ -1455,7 +1455,7 @@ impl ModulesAppearance { } } -fn validate_island( +fn validate_style( field: &str, border: &mut Option, padding: &mut Option, From f43fe3711232a02449ae2a1666fa1a526354b1b7 Mon Sep 17 00:00:00 2001 From: Simone Camito Date: Thu, 8 Oct 2026 11:54:31 +0200 Subject: [PATCH 3/3] feat(config): key module styles by their section name Builtin modules are styled under their snake_case section name, like their settings ([appearance.modules.system_info] next to [system_info]), and custom modules under [appearance.modules.custom.], so a custom module can't clash with a builtin or with the reserved keys. Any other key is rejected at parse time with a hint, instead of being taken as a custom module and silently styling nothing. A custom style without a matching [[CustomModule]] logs a warning. --- src/config.rs | 171 +++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 134 insertions(+), 37 deletions(-) diff --git a/src/config.rs b/src/config.rs index 53c1b4e6..a7ef49c8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1363,7 +1363,7 @@ pub struct GroupStyle { pub spacing: Option, } -/// `appearance.modules.`: per-module overrides. +/// `appearance.modules.`: per-module overrides. #[derive(Deserialize, Clone, Copy, Debug, Default, PartialEq)] pub struct ModuleStyle { pub radius: Option, @@ -1377,7 +1377,8 @@ pub struct ModuleStyle { } /// The `[appearance.modules]` table: the reserved `default` and `group` keys, -/// every other key is a module name as used in the layout. +/// builtin modules by their section name (`system_info`), and custom modules +/// under `custom` by their `name`. #[derive(Clone, Debug, Default, PartialEq)] pub struct ModulesAppearance { pub default: ModuleDefaultStyle, @@ -1398,15 +1399,21 @@ impl<'de> Deserialize<'de> for ModulesAppearance { fn visit_map>(self, mut map: M) -> Result { let mut modules = ModulesAppearance::default(); - while let Some(name) = map.next_key::()? { - match name { - ModuleName::Custom(key) if key == "default" => { - modules.default = map.next_value()? + while let Some(key) = map.next_key::()? { + match key.as_str() { + "default" => modules.default = map.next_value()?, + "group" => modules.group = map.next_value()?, + "custom" => { + let custom: HashMap = map.next_value()?; + modules.overrides.extend( + custom + .into_iter() + .map(|(name, style)| (ModuleName::Custom(name), style)), + ); } - ModuleName::Custom(key) if key == "group" => { - modules.group = map.next_value()? - } - name => { + key => { + let name = ModuleName::from_section(key) + .ok_or_else(|| de::Error::custom(unknown_module(key)))?; modules.overrides.insert(name, map.next_value()?); } } @@ -1437,24 +1444,36 @@ impl ModulesAppearance { ); for (name, style) in &mut self.overrides { validate_style( - &format!("appearance.modules.{name}"), + &format!("appearance.modules.{}", name.style_key()), &mut style.border, &mut style.padding, style.spacing.as_mut(), ); } - for module in custom_modules { - if matches!(module.name.as_str(), "default" | "group") { + for name in self.overrides.keys() { + if let ModuleName::Custom(name) = name + && !custom_modules.iter().any(|module| module.name == *name) + { warn!( - "Custom module name `{}` is reserved in [appearance.modules], its style cannot be overridden", - module.name + "[appearance.modules.custom.{name}] doesn't match any [[CustomModule]], its style is unused" ); } } } } +fn unknown_module(key: &str) -> String { + match BUILTIN_MODULES.iter().find(|(layout, _, _)| *layout == key) { + Some((_, section, _)) => { + format!("unknown module `{key}` in [appearance.modules], use `{section}`") + } + None => format!( + "unknown module `{key}` in [appearance.modules], expected a builtin module like `system_info`, or `custom.{key}` for a custom module" + ), + } +} + fn validate_style( field: &str, border: &mut Option, @@ -1761,29 +1780,63 @@ pub enum ModuleName { Notifications, } -const BUILTIN_MODULES: [(&str, ModuleName); 12] = [ - ("Updates", ModuleName::Updates), - ("Workspaces", ModuleName::Workspaces), - ("WindowTitle", ModuleName::WindowTitle), - ("SystemInfo", ModuleName::SystemInfo), - ("KeyboardLayout", ModuleName::KeyboardLayout), - ("KeyboardSubmap", ModuleName::KeyboardSubmap), - ("Tray", ModuleName::Tray), - ("Notifications", ModuleName::Notifications), - ("Tempo", ModuleName::Tempo), - ("Privacy", ModuleName::Privacy), - ("Settings", ModuleName::Settings), - ("MediaPlayer", ModuleName::MediaPlayer), +/// Each builtin module's layout name and its snake_case name, used for its +/// settings section and its `[appearance.modules]` key. +const BUILTIN_MODULES: [(&str, &str, ModuleName); 12] = [ + ("Updates", "updates", ModuleName::Updates), + ("Workspaces", "workspaces", ModuleName::Workspaces), + ("WindowTitle", "window_title", ModuleName::WindowTitle), + ("SystemInfo", "system_info", ModuleName::SystemInfo), + ( + "KeyboardLayout", + "keyboard_layout", + ModuleName::KeyboardLayout, + ), + ( + "KeyboardSubmap", + "keyboard_submap", + ModuleName::KeyboardSubmap, + ), + ("Tray", "tray", ModuleName::Tray), + ("Notifications", "notifications", ModuleName::Notifications), + ("Tempo", "tempo", ModuleName::Tempo), + ("Privacy", "privacy", ModuleName::Privacy), + ("Settings", "settings", ModuleName::Settings), + ("MediaPlayer", "media_player", ModuleName::MediaPlayer), ]; +impl ModuleName { + fn builtin(&self) -> Option<&'static (&'static str, &'static str, ModuleName)> { + BUILTIN_MODULES.iter().find(|(_, _, module)| module == self) + } + + fn from_section(section: &str) -> Option { + BUILTIN_MODULES + .iter() + .find(|(_, name, _)| *name == section) + .map(|(_, _, module)| module.clone()) + } + + /// The key of the module's table in `[appearance.modules]`. + fn style_key(&self) -> String { + match self { + ModuleName::Custom(name) => format!("custom.{name}"), + builtin => builtin + .builtin() + .expect("every builtin module has a name") + .1 + .to_owned(), + } + } +} + impl std::fmt::Display for ModuleName { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { let name = match self { ModuleName::Custom(name) => name, builtin => { - BUILTIN_MODULES - .iter() - .find(|(_, module)| module == builtin) + builtin + .builtin() .expect("every builtin module has a name") .0 } @@ -1809,10 +1862,10 @@ impl<'de> Deserialize<'de> for ModuleName { { Ok(BUILTIN_MODULES .iter() - .find(|(name, _)| *name == value) + .find(|(name, _, _)| *name == value) .map_or_else( || ModuleName::Custom(value.to_string()), - |(_, module)| module.clone(), + |(_, _, module)| module.clone(), )) } } @@ -2275,17 +2328,17 @@ mod tests { background = "background.weak" spacing = "xs" - [appearance.modules.SystemInfo] + [appearance.modules.system_info] grouping = "individual" spacing = "xs" radius = "md" border = { width = 1, color = "#414868" } text_color = "primary" - [appearance.modules.Workspaces] + [appearance.modules.workspaces] font_size = "sm" - [appearance.modules.my_custom_module] + [appearance.modules.custom.my_custom_module] font_size = "lg" "##, ) @@ -2398,7 +2451,51 @@ mod tests { #[test] fn rejects_unknown_grouping_and_font_size() { assert!(parse("[appearance.modules.default]\ngrouping = \"all\"").is_err()); - assert!(parse("[appearance.modules.Tray]\nfont_size = \"huge\"").is_err()); + assert!(parse("[appearance.modules.tray]\nfont_size = \"huge\"").is_err()); + } + + #[test] + fn module_overrides_use_section_names() { + let error = |toml: &str| parse(toml).unwrap_err().message().to_owned(); + + assert_eq!( + error("[appearance.modules.SystemInfo]"), + "unknown module `SystemInfo` in [appearance.modules], use `system_info`" + ); + assert!(error("[appearance.modules.my_module]").contains("`custom.my_module`")); + } + + #[test] + fn custom_overrides_stay_apart_from_builtins() { + let modules = parse( + r#" + [appearance.modules.tray] + font_size = "sm" + + [appearance.modules.custom.tray] + font_size = "lg" + + [appearance.modules.custom.default] + font_size = "xs" + "#, + ) + .unwrap() + .appearance + .modules; + + assert_eq!( + modules.overrides[&ModuleName::Tray].font_size, + Some(FontSizeScale::Sm) + ); + assert_eq!( + modules.overrides[&ModuleName::Custom("tray".into())].font_size, + Some(FontSizeScale::Lg) + ); + assert_eq!( + modules.overrides[&ModuleName::Custom("default".into())].font_size, + Some(FontSizeScale::Xs) + ); + assert_eq!(modules.default, ModuleDefaultStyle::default()); } #[test]