use serde::{Deserialize, Deserializer}; use std::collections::BTreeMap; #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] #[serde(rename_all = "snake_case")] pub enum PopupAnchor { TopLeft, TopCenter, TopRight, CenterLeft, Center, CenterRight, #[default] BottomLeft, BottomCenter, BottomRight, } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct PopupOptions { pub anchor: PopupAnchor, } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct PopupConfig { pub command: PopupOptions, pub search: PopupOptions, } impl<'de> Deserialize<'de> for PopupOptions { fn deserialize>(deserializer: D) -> Result { #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct Options { #[serde(default)] anchor: PopupAnchor, } let options: Options = deserialize_object(deserializer)?; Ok(Self { anchor: options.anchor, }) } } impl<'de> Deserialize<'de> for PopupConfig { fn deserialize>(deserializer: D) -> Result { #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct Options { #[serde(default)] command: PopupOptions, #[serde(default)] search: PopupOptions, } let options: Options = deserialize_object(deserializer)?; Ok(Self { command: options.command, search: options.search, }) } } // Struct derives also accept sequences; popup configuration accepts only objects. fn deserialize_object<'de, D: Deserializer<'de>, T: Deserialize<'de>>( deserializer: D, ) -> Result { struct ObjectVisitor(std::marker::PhantomData); impl<'de, T: Deserialize<'de>> serde::de::Visitor<'de> for ObjectVisitor { type Value = T; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { formatter.write_str("a popup configuration object") } fn visit_map>(self, map: M) -> Result { T::deserialize(serde::de::value::MapAccessDeserializer::new(map)) } } deserializer.deserialize_map(ObjectVisitor(std::marker::PhantomData)) } const DEFAULT_BOTTOM_SEGMENTS: &[&str] = &[ "mode", "pending", "buffer", "diagnostics", "path", "selections", "count", "language", "lsp", "position", ]; #[derive(Clone, Debug, Eq, PartialEq)] pub struct ChromeLine { pub left: Vec, pub center: Vec, pub right: Vec, pub segments: BTreeMap, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct SegmentConfig { pub title: String, pub padding: usize, pub margin: [usize; 2], pub leading_edge: String, pub trailing_edge: String, pub first_leading_edge: Option, } impl Default for SegmentConfig { fn default() -> Self { Self { title: String::new(), padding: 1, margin: [0; 2], leading_edge: String::new(), trailing_edge: String::new(), first_leading_edge: None, } } } impl ChromeLine { pub fn top() -> Self { Self { left: vec!["tabs".into()], center: Vec::new(), right: vec!["battery".into()], segments: BTreeMap::from([ ("tabs".into(), default_segment("tabs")), ("battery".into(), default_segment("battery")), ]), } } pub fn bottom() -> Self { Self { left: DEFAULT_BOTTOM_SEGMENTS[..DEFAULT_BOTTOM_SEGMENTS.len() - 3] .iter() .map(|name| (*name).into()) .collect(), center: Vec::new(), right: vec!["language".into(), "lsp".into(), "position".into()], segments: DEFAULT_BOTTOM_SEGMENTS .iter() .map(|name| { let mut segment = default_segment(name); if *name == "position" { segment.margin[0] = 1; } ((*name).into(), segment) }) .collect(), } } /// Styles are populated for every referenced name during configuration validation. pub fn segment(&self, name: &str) -> &SegmentConfig { self.segments.get(name).expect("validated chrome segment") } pub fn validate(&mut self) -> Result<(), String> { for name in self .left .iter() .chain(&self.center) .chain(&self.right) .chain(self.segments.keys()) { validate_name(name)?; } for (name, segment) in &self.segments { for (option, value) in [ ("padding", segment.padding), ("leading_margin", segment.margin[0]), ("trailing_margin", segment.margin[1]), ] { if value > 64 { return Err(format!( "Chrome segment {name:?} {option} must be between 0 and 64" )); } } for (option, text) in [ ("title", Some(&segment.title)), ("leading_edge", Some(&segment.leading_edge)), ("trailing_edge", Some(&segment.trailing_edge)), ("first_leading_edge", segment.first_leading_edge.as_ref()), ] { if text.is_some_and(|text| text.chars().any(char::is_control)) { return Err(format!( "Chrome segment {name:?} {option} must be single-line text without controls" )); } } validate_title(&segment.title) .map_err(|error| format!("Chrome segment {name:?}: {error}"))?; } for name in self.left.iter().chain(&self.center).chain(&self.right) { if !self.segments.contains_key(name) { self.segments.insert(name.clone(), default_segment(name)); } } Ok(()) } } fn default_segment(name: &str) -> SegmentConfig { let mut segment = SegmentConfig::default(); if matches!(name, "tabs" | "position" | "battery") { segment.leading_edge = "░▓".into(); } if matches!(name, "tabs" | "mode") { segment.trailing_edge = "▓░".into(); } if name == "tabs" { segment.title = "{index} {view}{name}{modified}".into(); segment.first_leading_edge = Some(String::new()); } segment } pub fn validate_name(name: &str) -> Result<(), String> { let mut bytes = name.bytes(); if !bytes .next() .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_') || !bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') { return Err(format!( "Label/segment name {name:?} must match [A-Za-z_][A-Za-z0-9_]*" )); } Ok(()) } fn validate_title(title: &str) -> Result<(), String> { let mut rest = title; while let Some(index) = rest.find(['{', '}']) { rest = &rest[index..]; if !rest.starts_with('{') { return Err("title contains an unmatched '}'".into()); } let end = rest.find('}').ok_or("title contains an unmatched '{'")?; let placeholder = &rest[1..end]; if !matches!(placeholder, "index" | "view" | "name" | "path" | "modified") { return Err(format!( "unknown title placeholder {{{placeholder}}}; expected index, view, name, path, or modified" )); } rest = &rest[end + 1..]; } Ok(()) } #[derive(Deserialize)] struct LineOptions { left: Option>, center: Option>, right: Option>, #[serde(flatten)] segments: BTreeMap, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct SegmentOptions { title: Option, padding: Option, margin: Option, leading_margin: Option, trailing_margin: Option, leading_edge: Option, trailing_edge: Option, first_leading_edge: Option, } fn deserialize_line<'de, D: Deserializer<'de>>( deserializer: D, mut line: ChromeLine, ) -> Result { // Decode through a value so explicit null is rejected rather than treated as an omitted option. let value = serde_json::Value::deserialize(deserializer)?; let object = value .as_object() .ok_or_else(|| serde::de::Error::custom("chrome line must be an object"))?; for (name, value) in object { if value.is_null() || value .as_object() .is_some_and(|options| options.values().any(serde_json::Value::is_null)) { return Err(serde::de::Error::custom(format!( "Chrome option {name:?} must not be null" ))); } } let options: LineOptions = serde_json::from_value(value).map_err(serde::de::Error::custom)?; if let Some(left) = options.left { line.left = left; } if let Some(center) = options.center { line.center = center; } if let Some(right) = options.right { line.right = right; } for (name, options) in options.segments { let segment = line .segments .entry(name.clone()) .or_insert_with(|| default_segment(&name)); if let Some(title) = options.title { segment.title = title; } if let Some(padding) = options.padding { segment.padding = padding; } if let Some(margin) = options.margin { segment.margin = [margin; 2]; } if let Some(margin) = options.leading_margin { segment.margin[0] = margin; } if let Some(margin) = options.trailing_margin { segment.margin[1] = margin; } if let Some(edge) = options.leading_edge { segment.leading_edge = edge; } if let Some(edge) = options.trailing_edge { segment.trailing_edge = edge; } if let Some(edge) = options.first_leading_edge { segment.first_leading_edge = Some(edge); } } line.validate().map_err(serde::de::Error::custom)?; Ok(line) } pub fn top<'de, D: Deserializer<'de>>(deserializer: D) -> Result { deserialize_line(deserializer, ChromeLine::top()) } pub fn bottom<'de, D: Deserializer<'de>>(deserializer: D) -> Result { deserialize_line(deserializer, ChromeLine::bottom()) } pub fn labels<'de, D: Deserializer<'de>>( deserializer: D, ) -> Result, D::Error> { let labels = BTreeMap::::deserialize(deserializer)?; for name in labels.keys() { validate_name(name).map_err(serde::de::Error::custom)?; } Ok(labels) } #[cfg(test)] #[path = "../tests/unit/chrome.rs"] mod tests;