From 5dc1709f58e7a4ea97a3a445ab366a3e5f30c315 Mon Sep 17 00:00:00 2001 From: thea Date: Thu, 2 Oct 2025 01:13:09 +1000 Subject: [PATCH] [+] Allow passing hex colors as preset (#435) --- crates/hyfetch/src/bin/hyfetch.rs | 43 ++++++++++++++++++++++++++++--- crates/hyfetch/src/cli_options.rs | 31 +++------------------- crates/hyfetch/src/models.rs | 3 +-- hyfetch/color_util.py | 14 +++++++--- hyfetch/main.py | 20 +++++++++++--- 5 files changed, 72 insertions(+), 39 deletions(-) diff --git a/crates/hyfetch/src/bin/hyfetch.rs b/crates/hyfetch/src/bin/hyfetch.rs index b24c9bf7..51f36368 100644 --- a/crates/hyfetch/src/bin/hyfetch.rs +++ b/crates/hyfetch/src/bin/hyfetch.rs @@ -3,6 +3,7 @@ use std::cmp; use std::fmt::Write as _; use std::fs::{self, File}; use std::io::{self, IsTerminal as _, Read as _, Write as _}; +use std::iter; use std::iter::zip; use std::num::NonZeroU8; use std::path::{Path, PathBuf}; @@ -24,7 +25,7 @@ use hyfetch::models::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}; -use hyfetch::presets::{AssignLightness, Preset}; +use hyfetch::presets::{AssignLightness, ColorProfile, Preset}; use hyfetch::pride_month; use hyfetch::types::{AnsiMode, Backend, TerminalTheme}; use hyfetch::utils::{get_cache_path, input}; @@ -129,9 +130,43 @@ 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 { + 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)); + } + } + 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()) + } 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()) + } + } + // Get preset - let preset = options.preset.unwrap_or(config.preset); - let color_profile = preset.color_profile(); + let preset_string = options.preset.as_deref().unwrap_or(&config.preset); + let color_profile = parse_preset_string(preset_string)?; debug!(?color_profile, "color profile"); // Lighten @@ -1155,7 +1190,7 @@ fn create_config( // Create config clear_screen(Some(&title), color_mode, debug_mode).context("failed to clear screen")?; let config = Config { - preset, + preset: preset.as_ref().to_string(), mode: color_mode, light_dark: Some(theme), auto_detect_light_dark: Some(det_bg.is_some()), diff --git a/crates/hyfetch/src/cli_options.rs b/crates/hyfetch/src/cli_options.rs index 8fcc1816..49c3184c 100644 --- a/crates/hyfetch/src/cli_options.rs +++ b/crates/hyfetch/src/cli_options.rs @@ -8,7 +8,7 @@ use bpaf::ShellComp; use bpaf::{construct, long, OptionParser, Parser as _}; use directories::BaseDirs; use itertools::Itertools as _; -use strum::{VariantArray, VariantNames}; +use strum::VariantNames; use crate::color_util::{color, Lightness}; use crate::presets::Preset; @@ -18,7 +18,7 @@ use crate::types::{AnsiMode, Backend}; pub struct Options { pub config: bool, pub config_file: PathBuf, - pub preset: Option, + pub preset: Option, pub mode: Option, pub backend: Option, pub args: Option>, @@ -55,7 +55,7 @@ pub fn options() -> OptionParser { let preset = long("preset") .short('p') .help(&*format!( - "Use preset + "Use preset or comma-separated color list or comma-separated hex colors (e.g., \"#ff0000,#00ff00,#0000ff\") PRESET={{{presets}}}", presets = ::VARIANTS .iter() @@ -65,30 +65,7 @@ PRESET={{{presets}}}", .argument::("PRESET"); #[cfg(feature = "autocomplete")] let preset = preset.complete(complete_preset); - let preset = preset - .parse(|s| { - Preset::from_str(&s) - .or_else(|e| { - if s == "random" { - let mut rng = fastrand::Rng::new(); - Ok(*rng - .choice(::VARIANTS) - .expect("preset iterator should not be empty")) - } else { - Err(e) - } - }) - .with_context(|| { - format!( - "PRESET should be one of {{{presets}}}", - presets = ::VARIANTS - .iter() - .chain(iter::once(&"random")) - .join(",") - ) - }) - }) - .optional(); + let preset = preset.optional(); let mode = long("mode") .short('m') .help(&*format!( diff --git a/crates/hyfetch/src/models.rs b/crates/hyfetch/src/models.rs index b8780155..5a8f1ac9 100644 --- a/crates/hyfetch/src/models.rs +++ b/crates/hyfetch/src/models.rs @@ -2,12 +2,11 @@ use serde::{Deserialize, Serialize}; use crate::color_util::Lightness; use crate::neofetch_util::ColorAlignment; -use crate::presets::Preset; use crate::types::{AnsiMode, Backend, TerminalTheme}; #[derive(Clone, Debug, Deserialize, Serialize)] pub struct Config { - pub preset: Preset, + pub preset: String, pub mode: AnsiMode, pub auto_detect_light_dark: Option, pub light_dark: Option, diff --git a/hyfetch/color_util.py b/hyfetch/color_util.py index d277526e..2ca4f22d 100644 --- a/hyfetch/color_util.py +++ b/hyfetch/color_util.py @@ -135,9 +135,17 @@ class RGB: :return: RGB object """ hex = hex.lstrip("#") - r = int(hex[0:2], 16) - g = int(hex[2:4], 16) - b = int(hex[4:6], 16) + + if len(hex) == 6: + r = int(hex[0:2], 16) + g = int(hex[2:4], 16) + b = int(hex[4:6], 16) + elif len(hex) == 3: + r = int(hex[0], 16) + g = int(hex[1], 16) + b = int(hex[2], 16) + else: + raise ValueError(f"Error: invalid hex length") return cls(r, g, b) def to_ansi_rgb(self, foreground: bool = True) -> str: diff --git a/hyfetch/main.py b/hyfetch/main.py index fb7e1053..c72d1167 100755 --- a/hyfetch/main.py +++ b/hyfetch/main.py @@ -18,7 +18,7 @@ from .constants import * from .font_logo import get_font_logo from .models import Config from .neofetch_util import * -from .presets import PRESETS +from .presets import PRESETS, ColorProfile def check_config(path) -> Config: @@ -379,7 +379,7 @@ def create_parser() -> argparse.ArgumentParser: parser.add_argument('-c', '--config', action='store_true', help=color(f'Configure hyfetch')) parser.add_argument('-C', '--config-file', dest='config_file', default=CONFIG_PATH, help=f'Use another config file') - parser.add_argument('-p', '--preset', help=f'Use preset', choices=list(PRESETS.keys()) + ['random']) + parser.add_argument('-p', '--preset', help=f'Use preset or comma-separated hex color list (e.g., "#ff0000,#00ff00,#0000ff")') parser.add_argument('-m', '--mode', help=f'Color mode', choices=['8bit', 'rgb']) parser.add_argument('-b', '--backend', help=f'Choose a *fetch backend', choices=['qwqfetch', 'neofetch', 'fastfetch', 'fastfetch-old']) parser.add_argument('--args', help=f'Additional arguments pass-through to backend') @@ -492,7 +492,21 @@ def run(): GLOBAL_CFG.is_light = config.light_dark == 'light' # Get preset - preset = PRESETS.get(config.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(',')] + + 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()))}') + + if preset is None: + exit(1) # Lighten (args > config) if args.scale: