#[cfg(feature = "app")]
use std::collections::HashMap;
#[cfg(feature = "app")]
use std::path::{Path, PathBuf};
#[cfg(feature = "app")]
use clap::Parser;
#[cfg(feature = "app")]
use serde::Deserialize;
#[cfg(feature = "app")]
use vello::peniko::Color;
#[cfg(feature = "app")]
use crate::consts::FONT_SIZE;
#[cfg(feature = "app")]
use crate::editor::EditorTheme;
pub const GITHUB_TOKEN_ENV: &str = "GITHUB_TOKEN";
#[cfg(feature = "app")]
#[derive(Parser, Debug, Clone)]
#[command(version, about, long_about = None)]
pub struct Config {
#[arg(short, long, required_unless_present = "demo")]
pub file: Option<PathBuf>,
#[arg(long)]
pub demo: bool,
#[arg(long)]
pub autosave: bool,
#[arg(long, env = GITHUB_TOKEN_ENV)]
pub github_token: Option<String>,
#[arg(long, env = "WRIT_GITHUB_REPO")]
pub github_repo: Option<String>,
#[arg(long)]
pub config: Option<PathBuf>,
}
#[cfg(feature = "app")]
#[derive(Debug, Clone)]
pub struct ResolvedConfig {
pub theme: EditorTheme,
pub font_family: Option<String>,
pub font_size: f32,
}
#[cfg(feature = "app")]
impl Default for ResolvedConfig {
fn default() -> Self {
Self {
theme: EditorTheme::dracula(),
font_family: None,
font_size: FONT_SIZE,
}
}
}
#[cfg(feature = "app")]
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct FileConfig {
font: Option<FontSpec>,
theme: Option<ThemeSpec>,
#[serde(default)]
themes: HashMap<String, Palette>,
}
#[cfg(feature = "app")]
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct FontSpec {
family: Option<String>,
size: Option<f32>,
}
#[cfg(feature = "app")]
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ThemeSpec {
name: Option<String>,
overrides: Option<Palette>,
}
#[cfg(feature = "app")]
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct Palette {
background: Option<String>,
surface: Option<String>,
foreground: Option<String>,
selection: Option<String>,
comment: Option<String>,
red: Option<String>,
orange: Option<String>,
yellow: Option<String>,
green: Option<String>,
cyan: Option<String>,
purple: Option<String>,
pink: Option<String>,
}
#[cfg(feature = "app")]
impl Palette {
fn apply(&self, theme: &mut EditorTheme) {
let set = |slot: &mut Color, field: &Option<String>, name: &str| {
if let Some(hex) = field {
match parse_hex(hex) {
Ok(c) => *slot = c,
Err(e) => eprintln!("[writ] config: {name}: {e}"),
}
}
};
set(&mut theme.background, &self.background, "background");
set(&mut theme.surface, &self.surface, "surface");
set(&mut theme.foreground, &self.foreground, "foreground");
set(&mut theme.selection, &self.selection, "selection");
set(&mut theme.comment, &self.comment, "comment");
set(&mut theme.red, &self.red, "red");
set(&mut theme.orange, &self.orange, "orange");
set(&mut theme.yellow, &self.yellow, "yellow");
set(&mut theme.green, &self.green, "green");
set(&mut theme.cyan, &self.cyan, "cyan");
set(&mut theme.purple, &self.purple, "purple");
set(&mut theme.pink, &self.pink, "pink");
}
}
#[cfg(feature = "app")]
fn parse_hex(s: &str) -> Result<Color, String> {
let t = s.trim();
let h = t.strip_prefix('#').unwrap_or(t);
if !h.is_ascii() {
return Err(format!("invalid hex color '{s}'"));
}
let byte = |i: usize| u8::from_str_radix(&h[i..i + 2], 16);
match h.len() {
6 => match (byte(0), byte(2), byte(4)) {
(Ok(r), Ok(g), Ok(b)) => Ok(Color::from_rgba8(r, g, b, 0xFF)),
_ => Err(format!("invalid hex color '{s}'")),
},
8 => match (byte(0), byte(2), byte(4), byte(6)) {
(Ok(r), Ok(g), Ok(b), Ok(a)) => Ok(Color::from_rgba8(r, g, b, a)),
_ => Err(format!("invalid hex color '{s}'")),
},
_ => Err(format!("expected #RRGGBB or #RRGGBBAA, got '{s}'")),
}
}
#[cfg(feature = "app")]
fn default_config_path() -> Option<PathBuf> {
dirs::config_dir().map(|d| d.join("writ").join("config.toml"))
}
#[cfg(feature = "app")]
pub fn load(explicit: Option<&Path>) -> ResolvedConfig {
let Some(path) = explicit.map(Path::to_path_buf).or_else(default_config_path) else {
return ResolvedConfig::default();
};
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if explicit.is_some() {
eprintln!("[writ] config: {} not found", path.display());
}
return ResolvedConfig::default();
}
Err(e) => {
eprintln!("[writ] config: cannot read {}: {e}", path.display());
return ResolvedConfig::default();
}
};
match toml::from_str::<FileConfig>(&text) {
Ok(file) => resolve(file),
Err(e) => {
eprintln!("[writ] config: {} parse error: {e}", path.display());
ResolvedConfig::default()
}
}
}
#[cfg(feature = "app")]
fn resolve(file: FileConfig) -> ResolvedConfig {
let mut cfg = ResolvedConfig::default();
if let Some(theme) = &file.theme {
if let Some(name) = &theme.name {
if let Some(custom) = file.themes.get(name) {
let mut base = EditorTheme::dracula();
custom.apply(&mut base);
cfg.theme = base;
} else if let Some(builtin) = EditorTheme::by_name(name) {
cfg.theme = builtin;
} else {
eprintln!("[writ] config: unknown theme '{name}', using dracula");
}
}
if let Some(overrides) = &theme.overrides {
overrides.apply(&mut cfg.theme);
}
}
if let Some(font) = &file.font {
if let Some(family) = &font.family {
let f = family.trim();
if !f.is_empty() {
cfg.font_family = Some(f.to_string());
}
}
if let Some(size) = font.size {
if size.is_finite() && (6.0..=96.0).contains(&size) {
cfg.font_size = size;
} else {
eprintln!(
"[writ] config: font.size {size} out of range (6–96), using {}",
cfg.font_size
);
}
}
}
cfg
}
#[cfg(all(test, feature = "app"))]
mod tests {
use super::*;
#[test]
fn hex_parsing() {
assert_eq!(
parse_hex("#FF0000").unwrap(),
Color::from_rgba8(255, 0, 0, 255)
);
assert_eq!(
parse_hex("00FF00").unwrap(),
Color::from_rgba8(0, 255, 0, 255)
);
assert_eq!(
parse_hex("#0000FF80").unwrap(),
Color::from_rgba8(0, 0, 255, 0x80)
);
assert!(parse_hex("#ZZ0000").is_err());
assert!(parse_hex("#FFF").is_err());
assert!(parse_hex("€000").is_err());
assert!(parse_hex("#café00").is_err());
}
#[test]
fn empty_config_is_default() {
let cfg = resolve(FileConfig::default());
assert_eq!(cfg.theme.background, EditorTheme::dracula().background);
assert!(cfg.font_family.is_none());
assert_eq!(cfg.font_size, FONT_SIZE);
}
#[test]
fn builtin_theme_by_name() {
let f: FileConfig = toml::from_str("[theme]\nname = \"solarized-light\"").unwrap();
let cfg = resolve(f);
assert_eq!(
cfg.theme.background,
EditorTheme::solarized_light().background
);
}
#[test]
fn unknown_theme_falls_back_to_dracula() {
let f: FileConfig = toml::from_str("[theme]\nname = \"nope\"").unwrap();
let cfg = resolve(f);
assert_eq!(cfg.theme.background, EditorTheme::dracula().background);
}
#[test]
fn overrides_apply_on_top_of_preset() {
let f: FileConfig =
toml::from_str("[theme]\nname = \"nord\"\n[theme.overrides]\nbackground = \"#010203\"")
.unwrap();
let cfg = resolve(f);
assert_eq!(cfg.theme.background, Color::from_rgba8(1, 2, 3, 255));
assert_eq!(cfg.theme.pink, EditorTheme::nord().pink);
}
#[test]
fn custom_theme_inherits_dracula_and_shadows_builtin() {
let f: FileConfig = toml::from_str(
"[theme]\nname = \"dracula\"\n[themes.dracula]\nbackground = \"#101010\"",
)
.unwrap();
let cfg = resolve(f);
assert_eq!(
cfg.theme.background,
Color::from_rgba8(0x10, 0x10, 0x10, 255)
);
assert_eq!(cfg.theme.foreground, EditorTheme::dracula().foreground);
}
#[test]
fn font_family_and_size() {
let f: FileConfig =
toml::from_str("[font]\nfamily = \"JetBrains Mono\"\nsize = 20.0").unwrap();
let cfg = resolve(f);
assert_eq!(cfg.font_family.as_deref(), Some("JetBrains Mono"));
assert_eq!(cfg.font_size, 20.0);
}
#[test]
fn unknown_key_is_rejected() {
assert!(toml::from_str::<FileConfig>("[font]\nfamly = \"x\"").is_err());
}
}