From e5ba2b3be9f2ee9011365fca52e443c491c13763 Mon Sep 17 00:00:00 2001 From: thea Date: Tue, 7 Apr 2026 11:09:52 +1000 Subject: [PATCH] [+] Add custom_presets config option (#481) * [+] Add custom_presets config option * [F] Resolve Copilot issues --- crates/hyfetch/src/bin/hyfetch.rs | 71 ++++++++++++++++++------------- crates/hyfetch/src/models.rs | 39 +++++++++++++++++ hyfetch/main.py | 47 ++++++++++++-------- hyfetch/models.py | 30 +++++++++++++ 4 files changed, 139 insertions(+), 48 deletions(-) diff --git a/crates/hyfetch/src/bin/hyfetch.rs b/crates/hyfetch/src/bin/hyfetch.rs index 028bdea4..1418b66b 100644 --- a/crates/hyfetch/src/bin/hyfetch.rs +++ b/crates/hyfetch/src/bin/hyfetch.rs @@ -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 { + fn parse_preset_string(preset_string: &str, config: &Config) -> Result { 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 = 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 = ::VARIANTS + .iter() + .map(|preset| (preset.as_ref().to_owned(), preset.color_profile())) + .collect(); + preset_profiles.extend(config.custom_preset_profiles()?); + + let presets: Vec = 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(::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 = ::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") -} +} \ No newline at end of file diff --git a/crates/hyfetch/src/models.rs b/crates/hyfetch/src/models.rs index 5a8f1ac9..ad6962d6 100644 --- a/crates/hyfetch/src/models.rs +++ b/crates/hyfetch/src/models.rs @@ -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, pub pride_month_disable: bool, pub custom_ascii_path: Option, + pub custom_presets: Option>>, } impl Config { @@ -32,6 +37,40 @@ impl Config { }, } } + + pub fn custom_preset_profiles(&self) -> Result> { + 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 { + 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 { diff --git a/hyfetch/main.py b/hyfetch/main.py index 95dc56fa..6e64bfcc 100755 --- a/hyfetch/main.py +++ b/hyfetch/main.py @@ -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) diff --git a/hyfetch/models.py b/hyfetch/models.py index 75095270..34da3d07 100644 --- a/hyfetch/models.py +++ b/hyfetch/models.py @@ -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