use std::{
borrow::{Borrow, Cow},
collections::BTreeMap,
ops::Index,
str::FromStr,
};
use crate::{
style::{Color, Style},
Error, Result,
};
#[doc = include_str!("./theme_keys.rs")]
pub const THEME_KEYS: &[&str] = &include!("./theme_keys.rs");
#[derive(Clone, Hash, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
pub struct Theme(BTreeMap<String, ThemeValue>);
#[derive(Clone, Hash, Debug, PartialEq, Eq)]
pub struct ResolvedTheme(BTreeMap<Cow<'static, str>, Style>);
#[derive(Clone, Hash, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(untagged))]
pub enum ThemeValue {
Simple(String),
Extended {
color: Option<String>,
bg: Option<String>,
#[cfg_attr(feature = "serde", serde(default))]
underline: bool,
#[cfg_attr(feature = "serde", serde(default))]
strikethrough: bool,
#[cfg_attr(feature = "serde", serde(default))]
italic: bool,
#[cfg_attr(feature = "serde", serde(default))]
bold: bool,
link: Option<String>,
},
}
impl Theme {
pub fn new(highlights: BTreeMap<String, ThemeValue>) -> Self {
Self(highlights)
}
pub fn into_inner(self) -> BTreeMap<String, ThemeValue> {
self.0
}
pub fn resolve_links(mut self) -> Result<ResolvedTheme> {
self.resolve_links_impl()?;
Ok(ResolvedTheme::new(
self.0
.into_iter()
.map(|(key, value)| {
Ok((
key.into(),
match value {
ThemeValue::Simple(color) => Style::new(
Color::from_str(&color)?,
None,
false,
false,
false,
false,
),
ThemeValue::Extended {
color,
bg,
underline,
strikethrough,
italic,
bold,
link: _,
} => Style::new(
Color::from_str(&color.expect("links have been resolved"))?,
bg.map(|color| Color::from_str(&color)).transpose()?,
underline,
strikethrough,
italic,
bold,
),
},
))
})
.collect::<Result<_>>()?,
))
}
fn resolve_links_impl(&mut self) -> Result<()> {
let mut must_reresolve = false;
let mut replacements = vec![];
for (key, value) in self.0.iter() {
let link_key = match value {
ThemeValue::Simple(str) if str.starts_with('$') => &str[1..],
ThemeValue::Extended {
link: Some(str), ..
} => str,
_ => continue,
};
let resolved = value.resolve_link(
self.0
.get(link_key)
.ok_or_else(|| Error::InvalidLink(link_key.to_owned()))?,
);
if matches!(&resolved, ThemeValue::Simple(str) if str.starts_with('$'))
|| matches!(&resolved, ThemeValue::Extended { link: Some(_), .. })
{
must_reresolve = true;
}
replacements.push((key.clone(), resolved));
}
for (key, replacement) in replacements {
*self.0.get_mut(&key).expect("key validity checked above") = replacement;
}
if must_reresolve {
self.resolve_links_impl()?;
}
Ok(())
}
}
impl From<BTreeMap<String, ThemeValue>> for Theme {
fn from(highlights: BTreeMap<String, ThemeValue>) -> Self {
Self::new(highlights)
}
}
impl ResolvedTheme {
pub fn new(highlights: BTreeMap<Cow<'static, str>, Style>) -> Self {
Self(highlights)
}
pub fn into_inner(self) -> BTreeMap<Cow<'static, str>, Style> {
self.0
}
pub fn get<Q>(&self, key: &Q) -> Option<&Style>
where
Cow<'static, str>: Borrow<Q>,
Q: Ord + ?Sized,
{
self.0.get(key)
}
pub fn fg(&self) -> Option<Color> {
self.get("_normal").map(|style| style.color())
}
pub fn bg(&self) -> Option<Color> {
self.get("_normal").and_then(|style| style.bg())
}
pub fn find_style(&self, mut key: &str) -> Option<Style> {
if let Some(style) = self.get(key) {
return Some(*style);
}
while let Some((rest, _)) = key.rsplit_once('.') {
if let Some(style) = self.get(rest) {
return Some(*style);
}
key = rest;
}
self.fg().map(Style::from)
}
}
impl<Q> Index<&Q> for ResolvedTheme
where
Cow<'static, str>: Borrow<Q>,
Q: Ord + ?Sized,
{
type Output = Style;
fn index(&self, key: &Q) -> &Self::Output {
self.get(key).expect("no entry found for key")
}
}
impl From<BTreeMap<Cow<'static, str>, Style>> for ResolvedTheme {
fn from(highlights: BTreeMap<Cow<'static, str>, Style>) -> Self {
Self::new(highlights)
}
}
impl TryFrom<Theme> for ResolvedTheme {
type Error = Error;
fn try_from(value: Theme) -> Result<Self> {
value.resolve_links()
}
}
impl ThemeValue {
fn resolve_link(&self, target: &Self) -> Self {
match (self, target) {
(ThemeValue::Simple(_), _) => target.clone(),
(
ThemeValue::Extended {
color: Some(color),
bg,
underline,
strikethrough,
italic,
bold,
link: _,
},
ThemeValue::Simple(_),
)
| (
ThemeValue::Extended {
color: None,
bg,
underline,
strikethrough,
italic,
bold,
link: _,
},
ThemeValue::Simple(color),
) => Self::Extended {
color: Some(color.clone()),
bg: bg.clone(),
underline: *underline,
strikethrough: *strikethrough,
italic: *italic,
bold: *bold,
link: None,
},
(
ThemeValue::Extended {
color: color @ Some(_),
bg,
underline,
strikethrough,
italic,
bold,
link: _,
},
ThemeValue::Extended {
color: _,
bg: other_bg,
underline: other_underline,
strikethrough: other_strikethrough,
italic: other_italic,
bold: other_bold,
link,
},
)
| (
ThemeValue::Extended {
color: None,
bg,
underline,
strikethrough,
italic,
bold,
link: _,
},
ThemeValue::Extended {
color,
bg: other_bg,
underline: other_underline,
strikethrough: other_strikethrough,
italic: other_italic,
bold: other_bold,
link,
},
) => Self::Extended {
color: color.clone(),
bg: bg.clone().or_else(|| other_bg.clone()),
underline: *underline || *other_underline,
strikethrough: *strikethrough || *other_strikethrough,
italic: *italic || *other_italic,
bold: *bold || *other_bold,
link: link.clone(),
},
}
}
}
#[macro_export(local_inner_macros)]
macro_rules! theme {
($($tt:tt)*) => {
theme_impl!($($tt)*)
};
}
#[macro_export(local_inner_macros)]
#[doc(hidden)]
macro_rules! theme_impl {
() => {};
($($key:literal : $value:tt),* $(,)?) => {{
let mut theme = ::std::collections::BTreeMap::new();
$(
theme.insert($key.to_owned(), theme_impl!(@value $value));
)*
$crate::theme::Theme::new(theme)
}};
(@value $str:literal) => {
$crate::theme::ThemeValue::Simple($str.to_owned())
};
(@value {
color: $color:tt,
bg: $bg:tt,
underline: $underline:expr,
strikethrough: $strikethrough:expr,
italic: $italic:expr,
bold: $bold:expr,
link: $link:tt $(,)?
}) => {
$crate::theme::ThemeValue::Extended {
color: theme_impl!(@option $color),
bg: theme_impl!(@option $bg),
underline: $underline,
strikethrough: $strikethrough,
italic: $italic,
bold: $bold,
link: theme_impl!(@option $link),
}
};
(@option None) => { None };
(@option $str:literal) => { Some($str.to_owned()) };
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn style_finding() {
let theme = theme! {
"keyword": "#000000",
"keyword.return": "#ff0000",
}
.resolve_links()
.unwrap();
assert_eq!(
theme.find_style("keyword.return"),
Some(Style::color_only(255, 0, 0)),
);
assert_eq!(
theme.find_style("keyword.operator"),
Some(Style::color_only(0, 0, 0)),
);
assert_eq!(
theme.find_style("keyword"),
Some(Style::color_only(0, 0, 0)),
);
assert_eq!(theme.find_style("other"), None);
}
#[test]
fn style_fallback() {
let theme = theme! {
"_normal": "#000000",
}
.resolve_links()
.unwrap();
assert_eq!(theme.find_style("other"), Some(Style::color_only(0, 0, 0)));
}
}