use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::gradient::Rgb;
use crate::mxc::contrast::Contrast;
use crate::theme::Theme;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Hex(pub Rgb);
impl From<Rgb> for Hex {
fn from(c: Rgb) -> Self {
Self(c)
}
}
impl From<Hex> for Rgb {
fn from(h: Hex) -> Self {
h.0
}
}
impl std::fmt::Display for Hex {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "#{:02x}{:02x}{:02x}", self.0.r, self.0.g, self.0.b)
}
}
impl Serialize for Hex {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.collect_str(self)
}
}
impl<'de> Deserialize<'de> for Hex {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use serde::de::Error;
let raw = String::deserialize(d)?;
let body = raw
.strip_prefix('#')
.ok_or_else(|| D::Error::custom(format!("color must start with '#', got {raw:?}")))?;
if body.len() != 6 || !body.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(D::Error::custom(format!(
"color must be 6 hex digits, got {raw:?}"
)));
}
let n = u32::from_str_radix(body, 16).map_err(D::Error::custom)?;
Ok(Hex(Rgb::new(
((n >> 16) & 0xff) as u8,
((n >> 8) & 0xff) as u8,
(n & 0xff) as u8,
)))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Colors {
pub primary: Hex,
pub secondary: Hex,
pub accent: Hex,
pub error: Hex,
pub warning: Hex,
pub success: Hex,
pub info: Hex,
pub text: Hex,
pub text_muted: Hex,
pub background: Hex,
pub background_panel: Hex,
pub background_element: Hex,
pub border: Hex,
pub border_active: Hex,
pub border_subtle: Hex,
pub border_dimmest: Hex,
}
impl From<&Theme> for Colors {
fn from(t: &Theme) -> Self {
Self {
primary: t.primary.into(),
secondary: t.secondary.into(),
accent: t.accent.into(),
error: t.error.into(),
warning: t.warning.into(),
success: t.success.into(),
info: t.info.into(),
text: t.text.into(),
text_muted: t.text_muted.into(),
background: t.background.into(),
background_panel: t.background_panel.into(),
background_element: t.background_element.into(),
border: t.border.into(),
border_active: t.border_active.into(),
border_subtle: t.border_subtle.into(),
border_dimmest: t.border_dimmest.into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OriginKind {
AlbumArt,
Builtin,
Fallback,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Origin {
pub kind: OriginKind,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub track: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artist: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub album: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub track_id: Option<String>,
}
impl Origin {
pub fn named(kind: OriginKind, name: impl Into<String>) -> Self {
Self {
kind,
name: name.into(),
track: None,
artist: None,
album: None,
track_id: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ByeReason {
Shutdown,
Reload,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ThemeEvent {
pub v: u32,
pub seq: u64,
pub ts: u64,
pub origin: Origin,
pub fade_ms: u32,
pub is_dark: bool,
pub colors: Colors,
pub contrast: Contrast,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ByeEvent {
pub v: u32,
pub seq: u64,
pub ts: u64,
pub reason: ByeReason,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "t", rename_all = "snake_case")]
pub enum Message {
Theme(ThemeEvent),
Bye(ByeEvent),
}
impl Message {
pub fn to_ndjson(&self) -> Result<String, serde_json::Error> {
let mut s = serde_json::to_string(self)?;
s.push('\n');
Ok(s)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mxc::PROTOCOL_VERSION;
fn sample_colors() -> Colors {
Colors {
primary: Hex(Rgb::new(0x64, 0xe0, 0xd0)),
secondary: Hex(Rgb::new(0x4a, 0x9f, 0xd8)),
accent: Hex(Rgb::new(0xf4, 0xaa, 0x48)),
error: Hex(Rgb::new(0xe0, 0x55, 0x61)),
warning: Hex(Rgb::new(0xd9, 0xa4, 0x41)),
success: Hex(Rgb::new(0x61, 0xc7, 0x66)),
info: Hex(Rgb::new(0x64, 0xe0, 0xd0)),
text: Hex(Rgb::new(0xd8, 0xef, 0xff)),
text_muted: Hex(Rgb::new(0x7a, 0x90, 0xa4)),
background: Hex(Rgb::new(0x08, 0x10, 0x18)),
background_panel: Hex(Rgb::new(0x10, 0x1d, 0x2a)),
background_element: Hex(Rgb::new(0x18, 0x29, 0x3a)),
border: Hex(Rgb::new(0x22, 0x37, 0x4a)),
border_active: Hex(Rgb::new(0x42, 0xd9, 0xd0)),
border_subtle: Hex(Rgb::new(0x18, 0x28, 0x38)),
border_dimmest: Hex(Rgb::new(0x10, 0x1c, 0x28)),
}
}
#[test]
fn hex_renders_lowercase_six_digits() {
assert_eq!(Hex(Rgb::new(0, 0, 0)).to_string(), "#000000");
assert_eq!(Hex(Rgb::new(255, 255, 255)).to_string(), "#ffffff");
assert_eq!(Hex(Rgb::new(0x0a, 0xb0, 0xcd)).to_string(), "#0ab0cd");
}
#[test]
fn hex_round_trips() {
let h = Hex(Rgb::new(0x64, 0xe0, 0xd0));
let s = serde_json::to_string(&h).unwrap();
assert_eq!(s, "\"#64e0d0\"");
assert_eq!(serde_json::from_str::<Hex>(&s).unwrap(), h);
}
#[test]
fn hex_rejects_garbage_instead_of_silently_blackening() {
for bad in ["64e0d0", "#64e0d", "#64e0d0ff", "#gggggg", "", "#"] {
let json = format!("\"{bad}\"");
assert!(
serde_json::from_str::<Hex>(&json).is_err(),
"{bad:?} must be rejected, not coerced"
);
}
}
#[test]
fn theme_message_is_flat_and_tagged() {
let msg = Message::Theme(ThemeEvent {
v: PROTOCOL_VERSION,
seq: 0,
ts: 1_785_616_484_123,
origin: Origin {
kind: OriginKind::AlbumArt,
name: "Blue Monday".into(),
track: Some("Blue Monday".into()),
artist: Some("New Order".into()),
album: Some("Power, Corruption & Lies".into()),
track_id: Some("spotify:track:0S8kQVVlLdvOOF4RgHUSBS".into()),
},
fade_ms: 600,
is_dark: true,
colors: sample_colors(),
contrast: Contrast::compute(&sample_colors()),
});
let v: serde_json::Value = serde_json::from_str(&msg.to_ndjson().unwrap()).unwrap();
assert_eq!(v["t"], "theme");
assert_eq!(v["v"], 1);
assert_eq!(v["seq"], 0);
assert_eq!(v["origin"]["kind"], "album_art");
assert_eq!(v["colors"]["primary"], "#64e0d0");
assert_eq!(v["fade_ms"], 600);
}
#[test]
fn all_sixteen_tokens_are_always_present() {
let json = serde_json::to_value(sample_colors()).unwrap();
let obj = json.as_object().unwrap();
assert_eq!(obj.len(), 16, "the palette is exactly 16 tokens");
for token in [
"primary",
"secondary",
"accent",
"error",
"warning",
"success",
"info",
"text",
"text_muted",
"background",
"background_panel",
"background_element",
"border",
"border_active",
"border_subtle",
"border_dimmest",
] {
assert!(obj.contains_key(token), "missing required token {token}");
}
}
#[test]
fn ndjson_is_exactly_one_line() {
let msg = Message::Bye(ByeEvent {
v: PROTOCOL_VERSION,
seq: 12,
ts: 1_785_616_999_000,
reason: ByeReason::Shutdown,
});
let line = msg.to_ndjson().unwrap();
assert_eq!(
line.matches('\n').count(),
1,
"framing requires one newline"
);
assert!(line.ends_with('\n'));
assert!(!line[..line.len() - 1].contains('\n'));
}
#[test]
fn bye_shape_matches_spec() {
let msg = Message::Bye(ByeEvent {
v: 1,
seq: 12,
ts: 1_785_616_999_000,
reason: ByeReason::Shutdown,
});
assert_eq!(
serde_json::to_string(&msg).unwrap(),
r#"{"t":"bye","v":1,"seq":12,"ts":1785616999000,"reason":"shutdown"}"#
);
}
#[test]
fn unknown_fields_are_ignored_not_fatal() {
let line = r#"{"t":"bye","v":1,"seq":1,"ts":1,"reason":"reload","future_field":{"a":1}}"#;
let msg: Message = serde_json::from_str(line).unwrap();
match msg {
Message::Bye(b) => assert_eq!(b.reason, ByeReason::Reload),
other => panic!("expected bye, got {other:?}"),
}
}
#[test]
fn optional_origin_metadata_is_omitted_when_absent() {
let o = Origin::named(OriginKind::Builtin, "tokyonight");
let s = serde_json::to_string(&o).unwrap();
assert_eq!(s, r#"{"kind":"builtin","name":"tokyonight"}"#);
}
}