[+] Add custom_presets config option (#481)

* [+] Add custom_presets config option

* [F] Resolve Copilot issues
This commit is contained in:
thea
2026-04-07 11:09:52 +10:00
committed by GitHub
parent e78e60da3d
commit e5ba2b3be9
4 changed files with 139 additions and 48 deletions
+40 -29
View File
@@ -1,5 +1,6 @@
use std::borrow::Cow; use std::borrow::Cow;
use std::cmp; use std::cmp;
use std::collections::HashMap;
use std::fmt::Write as _; use std::fmt::Write as _;
use std::fs::{self, File}; use std::fs::{self, File};
use std::io::{self, IsTerminal as _, Read as _, Write}; use std::io::{self, IsTerminal as _, Read as _, Write};
@@ -23,7 +24,7 @@ use hyfetch::color_util::{
NeofetchAsciiIndexedColor, PresetIndexedColor, Theme as _, ToAnsiString as _, NeofetchAsciiIndexedColor, PresetIndexedColor, Theme as _, ToAnsiString as _,
}; };
use hyfetch::distros::Distro; use hyfetch::distros::Distro;
use hyfetch::models::Config; use hyfetch::models::{build_hex_color_profile, Config};
#[cfg(feature = "macchina")] #[cfg(feature = "macchina")]
use hyfetch::neofetch_util::macchina_path; use hyfetch::neofetch_util::macchina_path;
use hyfetch::neofetch_util::{self, add_pkg_path, fastfetch_path, get_distro_ascii, get_distro_name, literal_input, ColorAlignment, NEOFETCH_COLORS_AC, NEOFETCH_COLOR_PATTERNS, TEST_ASCII}; use hyfetch::neofetch_util::{self, add_pkg_path, fastfetch_path, get_distro_ascii, get_distro_name, literal_input, ColorAlignment, NEOFETCH_COLORS_AC, NEOFETCH_COLOR_PATTERNS, TEST_ASCII};
@@ -135,43 +136,52 @@ fn main() -> Result<()> {
let backend = options.backend.unwrap_or(config.backend); let backend = options.backend.unwrap_or(config.backend);
let args = options.args.as_ref().or(config.args.as_ref()); let args = options.args.as_ref().or(config.args.as_ref());
fn parse_preset_string(preset_string: &str) -> Result<ColorProfile> { fn parse_preset_string(preset_string: &str, config: &Config) -> Result<ColorProfile> {
if preset_string.contains('#') { if preset_string.contains('#') {
let colors: Vec<&str> = preset_string.split(',').map(|s| s.trim()).collect(); let colors: Vec<String> = preset_string
for color in &colors { .split(',')
if !color.starts_with('#') || .map(|s| s.trim().to_owned())
(color.len() != 4 && color.len() != 7) || .collect();
!color[1..].chars().all(|c| c.is_ascii_hexdigit()) { let color_profile = build_hex_color_profile(&colors)
return Err(anyhow::anyhow!("invalid hex color: {}", color)); .context("failed to create color profile from hex")?;
} return Ok(color_profile);
}
let mut preset_profiles: HashMap<String, ColorProfile> = <Preset as VariantArray>::VARIANTS
.iter()
.map(|preset| (preset.as_ref().to_owned(), preset.color_profile()))
.collect();
preset_profiles.extend(config.custom_preset_profiles()?);
let presets: Vec<ColorProfile> = preset_profiles.values().cloned().collect();
if preset_string == "random" {
if presets.is_empty() {
return Err(anyhow::anyhow!("preset iterator should not be empty"));
} }
ColorProfile::from_hex_colors(colors)
.context("failed to create color profile from hex")
} else if preset_string == "random" {
let mut rng = fastrand::Rng::new(); let mut rng = fastrand::Rng::new();
let preset = *rng let selected_index = rng.usize(0..presets.len());
.choice(<Preset as VariantArray>::VARIANTS) return Ok(presets[selected_index].clone());
.expect("preset iterator should not be empty"); }
Ok(preset.color_profile())
if let Some(color_profile) = preset_profiles.get(preset_string) {
Ok(color_profile.clone())
} else { } else {
use std::str::FromStr; let presets = preset_profiles
let preset = Preset::from_str(preset_string) .keys()
.with_context(|| { .map(String::as_str)
format!( .chain(iter::once("random"))
"PRESET should be comma-separated hex colors or one of {{{presets}}}", .sorted()
presets = <Preset as VariantNames>::VARIANTS .join(",");
.iter() Err(anyhow::anyhow!(
.chain(iter::once(&"random")) "PRESET should be comma-separated hex colors or one of {{{presets}}}"
.join(",") ))
)
})?;
Ok(preset.color_profile())
} }
} }
// Get preset // Get preset
let preset_string = options.preset.as_deref().unwrap_or(&config.preset); let preset_string = options.preset.as_deref().unwrap_or(&config.preset);
let color_profile = parse_preset_string(preset_string)?; let color_profile = parse_preset_string(preset_string, &config)?;
debug!(?color_profile, "color profile"); debug!(?color_profile, "color profile");
// Lighten // Lighten
@@ -1219,6 +1229,7 @@ fn create_config(
distro: logo_chosen, distro: logo_chosen,
pride_month_disable: false, pride_month_disable: false,
custom_ascii_path, custom_ascii_path,
custom_presets: None,
}; };
debug!(?config, "created config"); debug!(?config, "created config");
+39
View File
@@ -1,5 +1,9 @@
use std::collections::HashMap;
use anyhow::{Context as _, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::color_profile::ColorProfile;
use crate::color_util::Lightness; use crate::color_util::Lightness;
use crate::neofetch_util::ColorAlignment; use crate::neofetch_util::ColorAlignment;
use crate::types::{AnsiMode, Backend, TerminalTheme}; use crate::types::{AnsiMode, Backend, TerminalTheme};
@@ -19,6 +23,7 @@ pub struct Config {
pub distro: Option<String>, pub distro: Option<String>,
pub pride_month_disable: bool, pub pride_month_disable: bool,
pub custom_ascii_path: Option<String>, pub custom_ascii_path: Option<String>,
pub custom_presets: Option<HashMap<String, Vec<String>>>,
} }
impl Config { impl Config {
@@ -32,6 +37,40 @@ impl Config {
}, },
} }
} }
pub fn custom_preset_profiles(&self) -> Result<HashMap<String, ColorProfile>> {
let mut profiles = HashMap::new();
if let Some(custom_presets) = &self.custom_presets {
for (preset_name, colors) in custom_presets {
if preset_name == "random" {
return Err(anyhow::anyhow!("custom preset key `random` is reserved"));
}
let color_profile = build_hex_color_profile(colors).with_context(|| {
format!("failed to validate custom preset key `{preset_name}`")
})?;
profiles.insert(preset_name.clone(), color_profile);
}
}
Ok(profiles)
}
}
pub fn build_hex_color_profile(hex_colors: &[String]) -> Result<ColorProfile> {
if hex_colors.is_empty() {
return Err(anyhow::anyhow!("hex color list cannot be empty"));
}
for color in hex_colors {
if !color.starts_with('#')
|| (color.len() != 4 && color.len() != 7)
|| !color[1..].chars().all(|c| c.is_ascii_hexdigit())
{
return Err(anyhow::anyhow!("invalid hex color: {color}"));
}
}
ColorProfile::from_hex_colors(hex_colors.to_vec())
.context("failed to create color profile from hex")
} }
mod args_serde { mod args_serde {
+29 -18
View File
@@ -17,7 +17,7 @@ from .color_scale import Scale
from .color_util import clear_screen from .color_util import clear_screen
from .constants import * from .constants import *
from .font_logo import get_font_logo from .font_logo import get_font_logo
from .models import Config from .models import build_hex_color_profile, Config
from .neofetch_util import * from .neofetch_util import *
from .presets import PRESETS, ColorProfile from .presets import PRESETS, ColorProfile
@@ -555,30 +555,41 @@ def run():
config.backend = args.backend config.backend = args.backend
if args.args: if args.args:
config.args = args.args config.args = args.args
# Random preset
if config.preset == 'random':
config.preset = random.choice(list(PRESETS.keys()))
# Override global color mode # Override global color mode
GLOBAL_CFG.color_mode = config.mode GLOBAL_CFG.color_mode = config.mode
GLOBAL_CFG.is_light = config.light_dark == 'light' GLOBAL_CFG.is_light = config.light_dark == 'light'
# Get preset # Get preset
preset = None def parse_preset_string(preset_string: str, config: Config) -> ColorProfile:
if config.preset in PRESETS: if '#' in preset_string:
preset = PRESETS.get(config.preset) colors = [s.strip() for s in preset_string.split(',')]
elif '#' in config.preset: return build_hex_color_profile(colors)
colors = [color.strip() for color in config.preset.split(',')]
for color in colors: preset_profiles: dict[str, ColorProfile] = {
if not (color.startswith('#') and len(color) in [4, 7] and all(c in '0123456789abcdefABCDEF' for c in color[1:])): name: preset for name, preset in PRESETS.items()
print(f'Error: invalid hex color "{color}"') }
preset = ColorProfile(colors) preset_profiles.update(config.custom_preset_profiles())
else:
print(f'Preset should be a comma-separated list of hex colors, or one of the following: {", ".join(sorted(PRESETS.keys()))}')
if preset is None: presets = list(preset_profiles.values())
if preset_string == 'random':
if not presets:
raise ValueError('preset iterator should not be empty')
return random.choice(presets)
if preset_string in preset_profiles:
return preset_profiles[preset_string]
available = sorted([*preset_profiles.keys(), 'random'])
raise ValueError(
"PRESET should be comma-separated hex colors or one of "
f"{{{','.join(available)}}}"
)
try:
preset = parse_preset_string(config.preset, config)
except ValueError as err:
print(f'Error: {err}')
exit(1) exit(1)
# Lighten (args > config) # Lighten (args > config)
+30
View File
@@ -4,10 +4,25 @@ from dataclasses import dataclass, field
from .constants import CONFIG_PATH from .constants import CONFIG_PATH
from .neofetch_util import ColorAlignment from .neofetch_util import ColorAlignment
from .presets import ColorProfile
from .serializer import json_stringify, from_dict from .serializer import json_stringify, from_dict
from .types import AnsiMode, LightDark, BackendLiteral from .types import AnsiMode, LightDark, BackendLiteral
def build_hex_color_profile(hex_colors: list[str]) -> ColorProfile:
if not hex_colors:
raise ValueError('hex color list cannot be empty')
for color in hex_colors:
if not (
color.startswith('#')
and len(color) in [4, 7]
and all(c in '0123456789abcdefABCDEF' for c in color[1:])
):
raise ValueError(f'invalid hex color: {color}')
return ColorProfile(hex_colors)
@dataclass @dataclass
class Config: class Config:
preset: str preset: str
@@ -21,6 +36,7 @@ class Config:
pride_month_shown: list[int] = field(default_factory=list) # This is deprecated, see issue #136 pride_month_shown: list[int] = field(default_factory=list) # This is deprecated, see issue #136
pride_month_disable: bool = False pride_month_disable: bool = False
custom_ascii_path: str | None = None custom_ascii_path: str | None = None
custom_presets: dict[str, list[str]] | None = None
@classmethod @classmethod
def from_dict(cls, d: dict): def from_dict(cls, d: dict):
@@ -30,3 +46,17 @@ class Config:
def save(self): def save(self):
CONFIG_PATH.parent.mkdir(exist_ok=True, parents=True) CONFIG_PATH.parent.mkdir(exist_ok=True, parents=True)
CONFIG_PATH.write_text(json_stringify(self, indent=4), 'utf-8') CONFIG_PATH.write_text(json_stringify(self, indent=4), 'utf-8')
def custom_preset_profiles(self) -> dict[str, ColorProfile]:
profiles: dict[str, ColorProfile] = {}
if self.custom_presets:
for preset_name, colors in self.custom_presets.items():
if preset_name == 'random':
raise ValueError('custom preset key "random" is reserved')
try:
profiles[preset_name] = build_hex_color_profile(colors)
except ValueError as err:
raise ValueError(
f'failed to validate custom preset key "{preset_name}": {err}'
) from err
return profiles