use std::collections::HashMap;
use ratatui::{text::Span, widgets::block::BorderType};
use serde::{de::Error, Deserialize};
use crate::config::CharSet;
#[derive(Deserialize, Debug)]
#[serde(deny_unknown_fields)]
pub struct CharSetOverlay {
inherit: Option<String>,
default_device: Option<String>,
default_stream: Option<String>,
selector_top: Option<String>,
selector_middle: Option<String>,
selector_bottom: Option<String>,
tab_marker_left: Option<String>,
tab_marker_right: Option<String>,
list_more: Option<String>,
volume_empty: Option<String>,
volume_filled: Option<String>,
meter_left_inactive: Option<String>,
meter_left_active: Option<String>,
meter_left_overload: Option<String>,
meter_right_inactive: Option<String>,
meter_right_active: Option<String>,
meter_right_overload: Option<String>,
meter_center_left_inactive: Option<String>,
meter_center_left_active: Option<String>,
meter_center_right_inactive: Option<String>,
meter_center_right_active: Option<String>,
dropdown_icon: Option<String>,
dropdown_selector: Option<String>,
dropdown_more: Option<String>,
dropdown_border: Option<BorderTypeDef>,
help_more: Option<String>,
help_border: Option<BorderTypeDef>,
}
#[derive(Deserialize, Debug)]
enum BorderTypeDef {
Plain,
Rounded,
Double,
Thick,
QuadrantInside,
QuadrantOutside,
}
impl From<BorderTypeDef> for BorderType {
fn from(def: BorderTypeDef) -> Self {
match def {
BorderTypeDef::Plain => Self::Plain,
BorderTypeDef::Rounded => Self::Rounded,
BorderTypeDef::Double => Self::Double,
BorderTypeDef::Thick => Self::Thick,
BorderTypeDef::QuadrantInside => Self::QuadrantInside,
BorderTypeDef::QuadrantOutside => Self::QuadrantOutside,
}
}
}
impl TryFrom<CharSetOverlay> for CharSet {
type Error = anyhow::Error;
fn try_from(overlay: CharSetOverlay) -> Result<Self, Self::Error> {
let mut char_set: Self = match overlay.inherit.as_deref() {
Some("default") => CharSet::default(),
Some("compat") => CharSet::compat(),
Some("extracompat") => CharSet::extracompat(),
Some(inherit) => {
anyhow::bail!("'{}' is not a built-in character set", inherit)
}
None => CharSet::default(),
};
macro_rules! validate_and_set {
($field:ident, $length:expr) => {
if let Some(value) = overlay.$field {
if $length > 0 && Span::raw(&value).width() != $length {
anyhow::bail!(
"{} must be {} characters wide",
stringify!($field),
$length
);
}
char_set.$field = value;
}
};
}
validate_and_set!(default_device, 1);
validate_and_set!(default_stream, 1);
validate_and_set!(selector_top, 1);
validate_and_set!(selector_middle, 1);
validate_and_set!(selector_bottom, 1);
validate_and_set!(tab_marker_left, 1);
validate_and_set!(tab_marker_right, 1);
validate_and_set!(list_more, 0);
validate_and_set!(volume_empty, 1);
validate_and_set!(volume_filled, 1);
validate_and_set!(meter_left_inactive, 1);
validate_and_set!(meter_left_active, 1);
validate_and_set!(meter_left_overload, 1);
validate_and_set!(meter_right_inactive, 1);
validate_and_set!(meter_right_active, 1);
validate_and_set!(meter_right_overload, 1);
validate_and_set!(meter_center_left_inactive, 1);
validate_and_set!(meter_center_left_active, 1);
validate_and_set!(meter_center_right_inactive, 1);
validate_and_set!(meter_center_right_active, 1);
validate_and_set!(dropdown_icon, 1);
validate_and_set!(dropdown_selector, 1);
validate_and_set!(dropdown_more, 0);
validate_and_set!(help_more, 0);
if let Some(dropdown_border) = overlay.dropdown_border {
char_set.dropdown_border = dropdown_border.into();
}
if let Some(help_border) = overlay.help_border {
char_set.help_border = help_border.into();
}
Ok(char_set)
}
}
impl Default for CharSet {
fn default() -> Self {
Self {
default_device: String::from("◇"),
default_stream: String::from("◇"),
selector_top: String::from("░"),
selector_middle: String::from("▒"),
selector_bottom: String::from("░"),
tab_marker_left: String::from("["),
tab_marker_right: String::from("]"),
list_more: String::from("•••"),
volume_empty: String::from("╌"),
volume_filled: String::from("━"),
meter_left_inactive: String::from("▮"),
meter_left_active: String::from("▮"),
meter_left_overload: String::from("▮"),
meter_right_inactive: String::from("▮"),
meter_right_active: String::from("▮"),
meter_right_overload: String::from("▮"),
meter_center_left_inactive: String::from("▮"),
meter_center_left_active: String::from("▮"),
meter_center_right_inactive: String::from("▮"),
meter_center_right_active: String::from("▮"),
dropdown_icon: String::from("▼"),
dropdown_selector: String::from(">"),
dropdown_more: String::from("•••"),
dropdown_border: BorderType::Rounded,
help_more: String::from("•••"),
help_border: BorderType::Rounded,
}
}
}
impl CharSet {
pub fn defaults() -> HashMap<String, CharSet> {
HashMap::from([
(String::from("default"), CharSet::default()),
(String::from("compat"), CharSet::compat()),
(String::from("extracompat"), CharSet::extracompat()),
])
}
fn compat() -> CharSet {
Self {
default_device: String::from("◊"),
default_stream: String::from("◊"),
selector_top: String::from("░"),
selector_middle: String::from("▒"),
selector_bottom: String::from("░"),
tab_marker_left: String::from("["),
tab_marker_right: String::from("]"),
list_more: String::from("•••"),
volume_empty: String::from("─"),
volume_filled: String::from("━"),
meter_left_inactive: String::from("┃"),
meter_left_active: String::from("┃"),
meter_left_overload: String::from("┃"),
meter_right_inactive: String::from("┃"),
meter_right_active: String::from("┃"),
meter_right_overload: String::from("┃"),
meter_center_left_inactive: String::from("█"),
meter_center_left_active: String::from("█"),
meter_center_right_inactive: String::from("█"),
meter_center_right_active: String::from("█"),
dropdown_icon: String::from("▼"),
dropdown_selector: String::from(">"),
dropdown_more: String::from("•••"),
dropdown_border: BorderType::Plain,
help_more: String::from("•••"),
help_border: BorderType::Plain,
}
}
fn extracompat() -> CharSet {
Self {
default_device: String::from("*"),
default_stream: String::from("*"),
selector_top: String::from("-"),
selector_middle: String::from("="),
selector_bottom: String::from("-"),
tab_marker_left: String::from("["),
tab_marker_right: String::from("]"),
list_more: String::from("~~~"),
volume_empty: String::from("-"),
volume_filled: String::from("="),
meter_left_inactive: String::from("="),
meter_left_active: String::from("#"),
meter_left_overload: String::from("!"),
meter_right_inactive: String::from("="),
meter_right_active: String::from("#"),
meter_right_overload: String::from("!"),
meter_center_left_inactive: String::from("["),
meter_center_left_active: String::from("["),
meter_center_right_inactive: String::from("]"),
meter_center_right_active: String::from("]"),
dropdown_icon: String::from("\\"),
dropdown_selector: String::from(">"),
dropdown_more: String::from("~~~"),
dropdown_border: BorderType::Plain,
help_more: String::from("~~~"),
help_border: BorderType::Plain,
}
}
pub fn merge<'de, D>(
deserializer: D,
) -> Result<HashMap<String, CharSet>, D::Error>
where
D: serde::Deserializer<'de>,
{
let configured =
HashMap::<String, CharSetOverlay>::deserialize(deserializer)?;
let mut merged = configured
.into_iter()
.map(|(key, value)| {
CharSet::try_from(value)
.map_err(D::Error::custom)
.map(move |charset| (key, charset))
})
.collect::<Result<HashMap<String, CharSet>, D::Error>>()?;
if !merged.contains_key("default") {
merged.insert(String::from("default"), CharSet::default());
}
if !merged.contains_key("compat") {
merged.insert(String::from("compat"), CharSet::compat());
}
if !merged.contains_key("extracompat") {
merged.insert(String::from("extracompat"), CharSet::extracompat());
}
Ok(merged)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_overlay() {
let config = r#""#;
let overlay = toml::from_str::<CharSetOverlay>(config).unwrap();
CharSet::try_from(overlay).unwrap();
}
#[test]
fn builtins_present() {
#[derive(Deserialize)]
struct S {
#[serde(deserialize_with = "CharSet::merge")]
char_sets: HashMap<String, CharSet>,
}
let config = r#"[char_sets.test]"#;
let s = toml::from_str::<S>(config).unwrap();
for name in CharSet::defaults().keys() {
assert!(s.char_sets.contains_key(name));
}
}
#[test]
fn override_default() {
let config = r#"
dropdown_icon = "$"
"#;
let overlay = toml::from_str::<CharSetOverlay>(config).unwrap();
let char_set = CharSet::try_from(overlay).unwrap();
assert_eq!(char_set.dropdown_icon, "$")
}
#[test]
fn width_too_narrow() {
let config = r#"
meter_right_active = ""
"#;
let overlay = toml::from_str::<CharSetOverlay>(config).unwrap();
let char_set = CharSet::try_from(overlay);
assert!(char_set.is_err());
}
#[test]
fn width_too_wide() {
let config = r#"
meter_right_active = "$$"
"#;
let overlay = toml::from_str::<CharSetOverlay>(config).unwrap();
let char_set = CharSet::try_from(overlay);
assert!(char_set.is_err());
}
#[test]
fn width_correct() {
let config = r#"
meter_right_active = "$"
"#;
let overlay = toml::from_str::<CharSetOverlay>(config).unwrap();
let char_set = CharSet::try_from(overlay).unwrap();
assert_eq!(char_set.meter_right_active, "$");
}
#[test]
fn width_1_column_grapheme_cluster() {
let config = r#"
meter_right_active = "⚓︎"
"#;
let overlay = toml::from_str::<CharSetOverlay>(config).unwrap();
let char_set = CharSet::try_from(overlay).unwrap();
assert_eq!(char_set.meter_right_active, "⚓︎");
}
#[test]
fn width_2_column_grapheme_cluster() {
let config = r#"
meter_right_active = "🏳️🌈"
"#;
let overlay = toml::from_str::<CharSetOverlay>(config).unwrap();
let char_set = CharSet::try_from(overlay);
assert!(char_set.is_err());
}
#[test]
fn width_unlimited() {
let config = r#"
list_more = ""
dropdown_more = "$$$$$$$$$$$$$$$$$$$$$$$$"
"#;
let overlay = toml::from_str::<CharSetOverlay>(config).unwrap();
let char_set = CharSet::try_from(overlay).unwrap();
assert_eq!(char_set.list_more, "");
assert_eq!(char_set.dropdown_more, "$$$$$$$$$$$$$$$$$$$$$$$$");
}
#[test]
fn inherit_nonexistent() {
let config = r#"
inherit = "doesntexist"
meter_right_active = "$"
"#;
let overlay = toml::from_str::<CharSetOverlay>(config).unwrap();
let char_set = CharSet::try_from(overlay);
assert!(char_set.is_err());
}
#[test]
fn inherit() {
for (builtin_key, builtin) in CharSet::defaults().iter() {
let config = format!(
r#"
inherit = "{builtin_key}"
meter_right_active = "$"
"#
);
let overlay = toml::from_str::<CharSetOverlay>(&config).unwrap();
let char_set = CharSet::try_from(overlay).unwrap();
assert_eq!(char_set.meter_right_active, "$");
assert_eq!(char_set.meter_left_active, builtin.meter_left_active);
}
}
#[test]
fn unknown_field() {
let config = r#"
unknown = "unknown"
"#;
assert!(toml::from_str::<CharSetOverlay>(config).is_err());
}
}