[+] 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
+41 -30
View File
@@ -1,5 +1,6 @@
use std::borrow::Cow;
use std::cmp;
use std::collections::HashMap;
use std::fmt::Write as _;
use std::fs::{self, File};
use std::io::{self, IsTerminal as _, Read as _, Write};
@@ -23,7 +24,7 @@ use hyfetch::color_util::{
NeofetchAsciiIndexedColor, PresetIndexedColor, Theme as _, ToAnsiString as _,
};
use hyfetch::distros::Distro;
use hyfetch::models::Config;
use hyfetch::models::{build_hex_color_profile, Config};
#[cfg(feature = "macchina")]
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};
@@ -135,43 +136,52 @@ fn main() -> Result<()> {
let backend = options.backend.unwrap_or(config.backend);
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('#') {
let colors: Vec<&str> = preset_string.split(',').map(|s| s.trim()).collect();
for color in &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));
}
let colors: Vec<String> = preset_string
.split(',')
.map(|s| s.trim().to_owned())
.collect();
let color_profile = build_hex_color_profile(&colors)
.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 preset = *rng
.choice(<Preset as VariantArray>::VARIANTS)
.expect("preset iterator should not be empty");
Ok(preset.color_profile())
let selected_index = rng.usize(0..presets.len());
return Ok(presets[selected_index].clone());
}
if let Some(color_profile) = preset_profiles.get(preset_string) {
Ok(color_profile.clone())
} else {
use std::str::FromStr;
let preset = Preset::from_str(preset_string)
.with_context(|| {
format!(
"PRESET should be comma-separated hex colors or one of {{{presets}}}",
presets = <Preset as VariantNames>::VARIANTS
.iter()
.chain(iter::once(&"random"))
.join(",")
)
})?;
Ok(preset.color_profile())
let presets = preset_profiles
.keys()
.map(String::as_str)
.chain(iter::once("random"))
.sorted()
.join(",");
Err(anyhow::anyhow!(
"PRESET should be comma-separated hex colors or one of {{{presets}}}"
))
}
}
// Get 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");
// Lighten
@@ -1219,6 +1229,7 @@ fn create_config(
distro: logo_chosen,
pride_month_disable: false,
custom_ascii_path,
custom_presets: None,
};
debug!(?config, "created config");
@@ -1297,4 +1308,4 @@ fn init_tracing_subsriber(debug_mode: bool) -> Result<()> {
subscriber
.try_init()
.context("failed to set the global default subscriber")
}
}
+39
View File
@@ -1,5 +1,9 @@
use std::collections::HashMap;
use anyhow::{Context as _, Result};
use serde::{Deserialize, Serialize};
use crate::color_profile::ColorProfile;
use crate::color_util::Lightness;
use crate::neofetch_util::ColorAlignment;
use crate::types::{AnsiMode, Backend, TerminalTheme};
@@ -19,6 +23,7 @@ pub struct Config {
pub distro: Option<String>,
pub pride_month_disable: bool,
pub custom_ascii_path: Option<String>,
pub custom_presets: Option<HashMap<String, Vec<String>>>,
}
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 {
+29 -18
View File
@@ -17,7 +17,7 @@ from .color_scale import Scale
from .color_util import clear_screen
from .constants import *
from .font_logo import get_font_logo
from .models import Config
from .models import build_hex_color_profile, Config
from .neofetch_util import *
from .presets import PRESETS, ColorProfile
@@ -555,30 +555,41 @@ def run():
config.backend = args.backend
if args.args:
config.args = args.args
# Random preset
if config.preset == 'random':
config.preset = random.choice(list(PRESETS.keys()))
# Override global color mode
GLOBAL_CFG.color_mode = config.mode
GLOBAL_CFG.is_light = config.light_dark == 'light'
# Get preset
preset = None
if config.preset in PRESETS:
preset = PRESETS.get(config.preset)
elif '#' in config.preset:
colors = [color.strip() for color in config.preset.split(',')]
def parse_preset_string(preset_string: str, config: Config) -> ColorProfile:
if '#' in preset_string:
colors = [s.strip() for s in preset_string.split(',')]
return build_hex_color_profile(colors)
for color in colors:
if not (color.startswith('#') and len(color) in [4, 7] and all(c in '0123456789abcdefABCDEF' for c in color[1:])):
print(f'Error: invalid hex color "{color}"')
preset = ColorProfile(colors)
else:
print(f'Preset should be a comma-separated list of hex colors, or one of the following: {", ".join(sorted(PRESETS.keys()))}')
preset_profiles: dict[str, ColorProfile] = {
name: preset for name, preset in PRESETS.items()
}
preset_profiles.update(config.custom_preset_profiles())
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)
# Lighten (args > config)
+30
View File
@@ -4,10 +4,25 @@ from dataclasses import dataclass, field
from .constants import CONFIG_PATH
from .neofetch_util import ColorAlignment
from .presets import ColorProfile
from .serializer import json_stringify, from_dict
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
class Config:
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_disable: bool = False
custom_ascii_path: str | None = None
custom_presets: dict[str, list[str]] | None = None
@classmethod
def from_dict(cls, d: dict):
@@ -30,3 +46,17 @@ class Config:
def save(self):
CONFIG_PATH.parent.mkdir(exist_ok=True, parents=True)
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