feat: configure chrome segments and anchor groups
This commit is contained in:
+313
@@ -0,0 +1,313 @@
|
||||
use serde::{Deserialize, Deserializer};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const STATUS_SEGMENTS: &[&str] = &[
|
||||
"mode",
|
||||
"pending",
|
||||
"buffer",
|
||||
"diagnostics",
|
||||
"path",
|
||||
"selections",
|
||||
"count",
|
||||
"language",
|
||||
"position",
|
||||
];
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ChromeLine {
|
||||
pub left: Vec<String>,
|
||||
pub center: Vec<String>,
|
||||
pub right: Vec<String>,
|
||||
pub segments: BTreeMap<String, SegmentConfig>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SegmentConfig {
|
||||
pub title: String,
|
||||
pub padding: usize,
|
||||
pub leading_edge: String,
|
||||
pub trailing_edge: String,
|
||||
pub first_leading_edge: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for SegmentConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
title: String::new(),
|
||||
padding: 1,
|
||||
leading_edge: String::new(),
|
||||
trailing_edge: String::new(),
|
||||
first_leading_edge: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ChromeLine {
|
||||
pub fn tabline() -> Self {
|
||||
Self {
|
||||
left: vec!["tabs".into()],
|
||||
center: Vec::new(),
|
||||
right: Vec::new(),
|
||||
segments: BTreeMap::from([("tabs".into(), default_segment("tabs"))]),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn statusline() -> Self {
|
||||
Self {
|
||||
left: STATUS_SEGMENTS[..STATUS_SEGMENTS.len() - 1]
|
||||
.iter()
|
||||
.map(|name| (*name).into())
|
||||
.collect(),
|
||||
center: Vec::new(),
|
||||
right: vec!["position".into()],
|
||||
segments: STATUS_SEGMENTS
|
||||
.iter()
|
||||
.map(|name| ((*name).into(), default_segment(name)))
|
||||
.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, tabline: bool) -> Result<(), String> {
|
||||
for name in self
|
||||
.left
|
||||
.iter()
|
||||
.chain(&self.center)
|
||||
.chain(&self.right)
|
||||
.chain(self.segments.keys())
|
||||
{
|
||||
validate_name(name)?;
|
||||
if (tabline && STATUS_SEGMENTS.contains(&name.as_str())) || (!tabline && name == "tabs")
|
||||
{
|
||||
return Err(format!(
|
||||
"Chrome segment {name:?} is only available in the {}",
|
||||
if tabline { "statusline" } else { "tabline" }
|
||||
));
|
||||
}
|
||||
}
|
||||
for (name, segment) in &self.segments {
|
||||
if segment.padding > 64 {
|
||||
return Err(format!(
|
||||
"Chrome segment {name:?} padding 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") {
|
||||
segment.leading_edge = "░▒▓".into();
|
||||
}
|
||||
if matches!(name, "tabs" | "mode") {
|
||||
segment.trailing_edge = "▓▒░".into();
|
||||
}
|
||||
if name == "tabs" {
|
||||
segment.title = "{index} {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" | "name" | "path" | "modified") {
|
||||
return Err(format!(
|
||||
"unknown title placeholder {{{placeholder}}}; expected index, name, path, or modified"
|
||||
));
|
||||
}
|
||||
rest = &rest[end + 1..];
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LineOptions {
|
||||
left: Option<Vec<String>>,
|
||||
center: Option<Vec<String>>,
|
||||
right: Option<Vec<String>>,
|
||||
#[serde(flatten)]
|
||||
segments: BTreeMap<String, SegmentOptions>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SegmentOptions {
|
||||
title: Option<String>,
|
||||
padding: Option<usize>,
|
||||
leading_edge: Option<String>,
|
||||
trailing_edge: Option<String>,
|
||||
first_leading_edge: Option<String>,
|
||||
}
|
||||
|
||||
fn deserialize_line<'de, D: Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
tabline: bool,
|
||||
) -> Result<ChromeLine, D::Error> {
|
||||
// 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)?;
|
||||
let mut line = if tabline {
|
||||
ChromeLine::tabline()
|
||||
} else {
|
||||
ChromeLine::statusline()
|
||||
};
|
||||
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(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(tabline).map_err(serde::de::Error::custom)?;
|
||||
Ok(line)
|
||||
}
|
||||
|
||||
pub fn tabline<'de, D: Deserializer<'de>>(deserializer: D) -> Result<ChromeLine, D::Error> {
|
||||
deserialize_line(deserializer, true)
|
||||
}
|
||||
|
||||
pub fn statusline<'de, D: Deserializer<'de>>(deserializer: D) -> Result<ChromeLine, D::Error> {
|
||||
deserialize_line(deserializer, false)
|
||||
}
|
||||
|
||||
pub fn labels<'de, D: Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<BTreeMap<String, String>, D::Error> {
|
||||
let labels = BTreeMap::<String, String>::deserialize(deserializer)?;
|
||||
for name in labels.keys() {
|
||||
validate_name(name).map_err(serde::de::Error::custom)?;
|
||||
}
|
||||
Ok(labels)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Lines {
|
||||
#[serde(deserialize_with = "tabline")]
|
||||
top: ChromeLine,
|
||||
#[serde(deserialize_with = "statusline")]
|
||||
bottom: ChromeLine,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_options_inherit_and_explicit_groups_replace_defaults() {
|
||||
let lines: Lines = serde_json::from_value(serde_json::json!({
|
||||
"top": {"right": ["battery"], "tabs": {"title": "T{index}:{path}{modified}"}, "battery": {"padding": 0}},
|
||||
"bottom": {"left": [], "center": ["workspace"]}
|
||||
})).unwrap();
|
||||
assert_eq!(lines.top.left, ["tabs"]);
|
||||
assert_eq!(lines.top.segment("tabs").leading_edge, "░▒▓");
|
||||
assert_eq!(lines.top.segment("battery").padding, 0);
|
||||
assert!(lines.bottom.left.is_empty());
|
||||
assert_eq!(lines.bottom.right, ["position"]);
|
||||
assert_eq!(lines.bottom.segment("workspace"), &SegmentConfig::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_chrome_is_rejected_at_the_boundary() {
|
||||
for top in [
|
||||
serde_json::json!({"left": ["bad-name"]}),
|
||||
serde_json::json!({"left": ["mode"]}),
|
||||
serde_json::json!({"left": null}),
|
||||
serde_json::json!({"tabs": {"padding": 65}}),
|
||||
serde_json::json!({"tabs": {"padding": null}}),
|
||||
serde_json::json!({"tabs": {"leading_edge": "\n"}}),
|
||||
serde_json::json!({"tabs": {"title": "{unknown}"}}),
|
||||
serde_json::json!({"tabs": {"title": "{name"}}),
|
||||
serde_json::json!({"tabs": {"title": "name}"}}),
|
||||
serde_json::json!({"tabs": {"colour": "red"}}),
|
||||
] {
|
||||
assert!(
|
||||
serde_json::from_value::<Lines>(serde_json::json!({"top": top, "bottom": {}}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user