Files
vamp/src/chrome.rs
T
2026-10-16 18:53:02 +02:00

368 lines
11 KiB
Rust

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<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[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<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[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<T, D::Error> {
struct ObjectVisitor<T>(std::marker::PhantomData<T>);
impl<'de, T: Deserialize<'de>> serde::de::Visitor<'de> for ObjectVisitor<T> {
type Value = T;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a popup configuration object")
}
fn visit_map<M: serde::de::MapAccess<'de>>(self, map: M) -> Result<T, M::Error> {
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<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 margin: [usize; 2],
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,
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<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>,
margin: Option<usize>,
leading_margin: Option<usize>,
trailing_margin: Option<usize>,
leading_edge: Option<String>,
trailing_edge: Option<String>,
first_leading_edge: Option<String>,
}
fn deserialize_line<'de, D: Deserializer<'de>>(
deserializer: D,
mut line: ChromeLine,
) -> 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)?;
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<ChromeLine, D::Error> {
deserialize_line(deserializer, ChromeLine::top())
}
pub fn bottom<'de, D: Deserializer<'de>>(deserializer: D) -> Result<ChromeLine, D::Error> {
deserialize_line(deserializer, ChromeLine::bottom())
}
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)]
#[path = "../tests/unit/chrome.rs"]
mod tests;