forked from mars/breed
312 lines
8.9 KiB
Rust
312 lines
8.9 KiB
Rust
|
/*
|
||
|
* Copyright (c) 2023 Marceline Cramer
|
||
|
* Copyright (c) 2023 Emma Tebibyte <emma@tebibyte.media>
|
||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||
|
*
|
||
|
* This program is free software: you can redistribute it and/or modify it under
|
||
|
* the terms of the GNU Affero General Public License as published by the Free
|
||
|
* Software Foundation, either version 3 of the License, or (at your option) any
|
||
|
* later version.
|
||
|
*
|
||
|
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||
|
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
||
|
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||
|
* details.
|
||
|
*
|
||
|
* You should have received a copy of the GNU Affero General Public License
|
||
|
* along with this program. If not, see https://www.gnu.org/licenses/.
|
||
|
*/
|
||
|
|
||
|
use std::collections::HashMap;
|
||
|
use std::io::Write;
|
||
|
|
||
|
use crossterm::{style::Color, ExecutableCommand};
|
||
|
use once_cell::sync::Lazy;
|
||
|
use toml::{map::Map, Value};
|
||
|
|
||
|
pub static DEFAULT_THEME: Lazy<Theme> = Lazy::new(|| {
|
||
|
let text = include_str!("../default_theme.toml");
|
||
|
let value = toml::from_str(text).expect("Failed to parse default theme");
|
||
|
Theme::from_value("default", value).expect("Failed to load default theme")
|
||
|
});
|
||
|
|
||
|
#[derive(Debug)]
|
||
|
pub struct StyleStore {
|
||
|
/// The current theme.
|
||
|
theme: Theme,
|
||
|
|
||
|
/// The current generation of the store. Increments every update.
|
||
|
generation: usize,
|
||
|
|
||
|
/// Maps named scopes to indices.
|
||
|
scopes: HashMap<String, usize>,
|
||
|
|
||
|
/// The style data store.
|
||
|
styles: Vec<Style>,
|
||
|
}
|
||
|
|
||
|
impl Default for StyleStore {
|
||
|
fn default() -> Self {
|
||
|
let theme = Lazy::force(&DEFAULT_THEME).to_owned();
|
||
|
Self::new(theme)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
impl StyleStore {
|
||
|
/// Creates a new style store with a given theme.
|
||
|
pub fn new(theme: Theme) -> Self {
|
||
|
Self {
|
||
|
theme,
|
||
|
generation: 0,
|
||
|
scopes: HashMap::new(),
|
||
|
styles: Vec::new(),
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/// Adds a scope to this store and returns its index. Reuses indices for existing scopes.
|
||
|
pub fn add_scope(&mut self, scope: &str) -> usize {
|
||
|
let style = self.theme.get_scope_style(scope);
|
||
|
|
||
|
if let Some(style_idx) = self.scopes.get(scope).copied() {
|
||
|
let old_style = self
|
||
|
.styles
|
||
|
.get_mut(style_idx)
|
||
|
.expect("StyleStore index is out-of-bounds");
|
||
|
let _ = std::mem::replace(old_style, style);
|
||
|
style_idx
|
||
|
} else {
|
||
|
let style_idx = self.styles.len();
|
||
|
self.styles.push(style);
|
||
|
self.scopes.insert(scope.to_string(), style_idx);
|
||
|
style_idx
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/// Gets the style for a scope by name.
|
||
|
pub fn get_scope(&mut self, scope: &str) -> Style {
|
||
|
let idx = self.add_scope(scope);
|
||
|
self.get_style(idx)
|
||
|
}
|
||
|
|
||
|
/// Gets the style for a scope index. Panics if out-of-bounds.
|
||
|
pub fn get_style(&self, index: usize) -> Style {
|
||
|
self.styles
|
||
|
.get(index)
|
||
|
.expect("StyleStore is out-of-bounds")
|
||
|
.to_owned()
|
||
|
}
|
||
|
|
||
|
/// The current generation of the store. Increments every update.
|
||
|
pub fn generation(&self) -> usize {
|
||
|
self.generation
|
||
|
}
|
||
|
|
||
|
/// Applies a theme to this store and updates the generation.
|
||
|
pub fn apply(&mut self, theme: Theme) {
|
||
|
self.generation += 1;
|
||
|
self.theme = theme;
|
||
|
|
||
|
for (scope, style_idx) in self.scopes.iter() {
|
||
|
let new_style = self.theme.get_scope_style(scope);
|
||
|
let old_style = self.styles.get_mut(*style_idx).unwrap();
|
||
|
let _ = std::mem::replace(old_style, new_style);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||
|
pub struct Style {
|
||
|
pub fg: Option<Color>,
|
||
|
pub bg: Option<Color>,
|
||
|
}
|
||
|
|
||
|
impl Style {
|
||
|
pub fn print_styled(
|
||
|
&self,
|
||
|
out: &mut (impl ExecutableCommand + Write),
|
||
|
content: &str,
|
||
|
) -> crossterm::Result<()> {
|
||
|
use crossterm::style::{ResetColor, SetBackgroundColor, SetForegroundColor};
|
||
|
|
||
|
if let Some(fg) = self.fg {
|
||
|
out.execute(SetForegroundColor(fg))?;
|
||
|
}
|
||
|
|
||
|
if let Some(bg) = self.bg {
|
||
|
out.execute(SetBackgroundColor(bg))?;
|
||
|
}
|
||
|
|
||
|
write!(out, "{}", content)?;
|
||
|
|
||
|
out.execute(ResetColor)?;
|
||
|
|
||
|
Ok(())
|
||
|
}
|
||
|
}
|
||
|
|
||
|
#[derive(Clone, Debug, Default)]
|
||
|
pub struct Theme {
|
||
|
pub name: String,
|
||
|
pub styles: HashMap<String, Style>,
|
||
|
}
|
||
|
|
||
|
impl Theme {
|
||
|
pub fn from_value(name: &str, value: Value) -> Result<Self, String> {
|
||
|
if let Value::Table(table) = value {
|
||
|
Self::from_map(name, table)
|
||
|
} else {
|
||
|
panic!("Expected theme TOML value to be a table, found {:?}", value);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
pub fn from_map(name: &str, mut values: Map<String, Value>) -> Result<Self, String> {
|
||
|
let palette = values
|
||
|
.remove("palette")
|
||
|
.map(Palette::try_from)
|
||
|
.unwrap_or_else(|| Ok(Palette::default()))?;
|
||
|
|
||
|
let mut styles = HashMap::with_capacity(values.len());
|
||
|
|
||
|
for (name, style) in values {
|
||
|
let style = palette.parse_style(style)?;
|
||
|
styles.insert(name.to_string(), style);
|
||
|
}
|
||
|
|
||
|
Ok(Self {
|
||
|
name: name.to_string(),
|
||
|
styles,
|
||
|
})
|
||
|
}
|
||
|
|
||
|
/// Gets a style for a scope, matching the longest available scope.
|
||
|
///
|
||
|
/// If no match is found, defaults to the default [Style].
|
||
|
pub fn get_scope_style(&self, scope: &str) -> Style {
|
||
|
let mut longest = Style::default();
|
||
|
let mut longest_len = 0;
|
||
|
|
||
|
for (target, style) in self.styles.iter() {
|
||
|
if target.len() > longest_len && scope.starts_with(target) {
|
||
|
longest = *style;
|
||
|
longest_len = target.len();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
longest
|
||
|
}
|
||
|
}
|
||
|
|
||
|
pub struct Palette {
|
||
|
pub palette: HashMap<String, Color>,
|
||
|
}
|
||
|
|
||
|
impl Default for Palette {
|
||
|
fn default() -> Self {
|
||
|
use Color::*;
|
||
|
let pairs = &[
|
||
|
("black", Black),
|
||
|
("red", DarkRed),
|
||
|
("green", DarkGreen),
|
||
|
("yellow", DarkYellow),
|
||
|
("blue", DarkBlue),
|
||
|
("magenta", DarkMagenta),
|
||
|
("cyan", DarkCyan),
|
||
|
("gray", DarkGrey),
|
||
|
("grey", DarkGrey),
|
||
|
("light-red", Red),
|
||
|
("light-green", Green),
|
||
|
("light-yellow", Yellow),
|
||
|
("light-blue", Blue),
|
||
|
("light-magenta", Magenta),
|
||
|
("light-cyan", Cyan),
|
||
|
("light-gray", Grey),
|
||
|
("light-grey", Grey),
|
||
|
("white", White),
|
||
|
];
|
||
|
|
||
|
Self {
|
||
|
palette: HashMap::from_iter(
|
||
|
pairs
|
||
|
.into_iter()
|
||
|
.map(|(name, color)| (name.to_string(), *color)),
|
||
|
),
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
impl TryFrom<Value> for Palette {
|
||
|
type Error = String;
|
||
|
|
||
|
fn try_from(value: Value) -> Result<Self, String> {
|
||
|
let values = value.as_table().ok_or(format!(
|
||
|
"Theme: expected table for palette but got: {:?}",
|
||
|
value
|
||
|
))?;
|
||
|
|
||
|
let default = Self::default();
|
||
|
let mut palette = HashMap::with_capacity(values.len());
|
||
|
|
||
|
for (name, value) in values {
|
||
|
let color = default.parse_color(value)?;
|
||
|
palette.insert(name.to_string(), color);
|
||
|
}
|
||
|
|
||
|
Ok(Self { palette })
|
||
|
}
|
||
|
}
|
||
|
|
||
|
impl Palette {
|
||
|
pub fn hex_string_to_rgb(s: &str) -> Result<Color, String> {
|
||
|
if s.starts_with('#') && s.len() == 7 {
|
||
|
if let (Ok(r), Ok(g), Ok(b)) = (
|
||
|
u8::from_str_radix(&s[1..3], 16),
|
||
|
u8::from_str_radix(&s[3..5], 16),
|
||
|
u8::from_str_radix(&s[5..7], 16),
|
||
|
) {
|
||
|
return Ok(Color::Rgb { r, g, b });
|
||
|
}
|
||
|
}
|
||
|
|
||
|
Err(format!("Theme: malformed hexcode: {}", s))
|
||
|
}
|
||
|
|
||
|
pub fn parse_value_as_str(value: &Value) -> Result<&str, String> {
|
||
|
value
|
||
|
.as_str()
|
||
|
.ok_or(format!("Theme: expected string but got: {:?}", value))
|
||
|
}
|
||
|
|
||
|
pub fn parse_color(&self, value: &Value) -> Result<Color, String> {
|
||
|
let str = Self::parse_value_as_str(&value)?;
|
||
|
|
||
|
self.palette
|
||
|
.get(str)
|
||
|
.copied()
|
||
|
.ok_or("")
|
||
|
.or_else(|_| Self::hex_string_to_rgb(&str))
|
||
|
}
|
||
|
|
||
|
pub fn parse_style_map(&self, entries: Map<String, Value>) -> Result<Style, String> {
|
||
|
let mut style = Style::default();
|
||
|
for (name, value) in entries {
|
||
|
match name.as_str() {
|
||
|
"fg" => style.fg = Some(self.parse_color(&value)?),
|
||
|
"bg" => style.bg = Some(self.parse_color(&value)?),
|
||
|
_ => return Err(format!("Theme: invalid style attribute: {}", name)),
|
||
|
}
|
||
|
}
|
||
|
|
||
|
Ok(style)
|
||
|
}
|
||
|
|
||
|
pub fn parse_style(&self, value: Value) -> Result<Style, String> {
|
||
|
if let Value::Table(entries) = value {
|
||
|
self.parse_style_map(entries)
|
||
|
} else {
|
||
|
let mut style = Style::default();
|
||
|
style.fg = Some(self.parse_color(&value)?);
|
||
|
Ok(style)
|
||
|
}
|
||
|
}
|
||
|
}
|