368 lines
11 KiB
Rust
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;
|