feat: configure chrome segments and anchor groups

This commit is contained in:
Valdemar Lorenzen
2026-10-12 11:07:40 +02:00
parent d55b7b65d2
commit 4cfd068f1c
14 changed files with 1731 additions and 371 deletions
+313
View File
@@ -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()
);
}
}
}