diff --git a/crates/hyfetch/src/bin/hyfetch.rs b/crates/hyfetch/src/bin/hyfetch.rs index a8d36741..0479ec85 100644 --- a/crates/hyfetch/src/bin/hyfetch.rs +++ b/crates/hyfetch/src/bin/hyfetch.rs @@ -309,6 +309,176 @@ fn det_bg() -> Result>, terminal_colorsaurus::Error> { } } +struct RawModeGuard { + enabled: bool, +} + +impl RawModeGuard { + fn new() -> Result { + Ok(Self { enabled: false }) + } + + fn enable(&mut self) -> Result<()> { + if !self.enabled { + enable_raw_mode().context("failed to enable terminal raw mode")?; + self.enabled = true; + } + Ok(()) + } + + fn disable(&mut self) -> Result<()> { + if self.enabled { + disable_raw_mode().context("failed to disable terminal raw mode")?; + self.enabled = false; + } + Ok(()) + } +} + +impl Drop for RawModeGuard { + fn drop(&mut self) { + if self.enabled { + let _ = disable_raw_mode(); + } + } +} + +fn print_title_prompt(option_counter: NonZeroU8, prompt: &str) { + printc!("&a{option_counter}. {prompt}"); +} + +fn parse_hex_colors(text: &str) -> Vec { + text.split(|c: char| c.is_whitespace() || c == ',' || c == ';') + .filter(|s| !s.is_empty()) + .filter_map(|tok| { + let hex = tok.strip_prefix('#').unwrap_or(tok); + if (hex.len() == 3 || hex.len() == 6) && hex.chars().all(|c| c.is_ascii_hexdigit()) { + Some(format!("#{hex}")) + } else { + None + } + }) + .collect() +} + +fn prompt_custom_flag( + title: &str, + option_counter: NonZeroU8, + color_mode: AnsiMode, + theme: TerminalTheme, + default_lightness: Lightness, + spacing: usize, + debug_mode: bool, +) -> Result { + let mut input_text = String::new(); + let mut hint: Option<&str> = None; + let mut raw_mode = RawModeGuard::new().context("failed to initialize raw input mode")?; + + loop { + raw_mode + .disable() + .context("failed to disable raw mode for rendering")?; + + clear_screen(Some(title), color_mode, debug_mode).context("failed to clear screen")?; + print_title_prompt(option_counter, "Let's set up your custom flag!"); + println!( + "Enter colors separated by spaces or commas (e.g. ff0000 00ff00 0000ff or #f00 #0f0):\n" + ); + + let colors = parse_hex_colors(&input_text); + if !colors.is_empty() { + if let Ok(profile) = build_hex_color_profile(&colors) { + if let Ok(flag_preview) = profile + .with_lightness_adaptive(default_lightness, theme) + .color_text( + &" ".repeat(spacing), + color_mode, + ForegroundBackground::Background, + false, + ) + { + println!("Flag preview:"); + printc(&flag_preview, color_mode)?; + printc(&flag_preview, color_mode)?; + printc(&flag_preview, color_mode)?; + let swatches = profile + .colors + .iter() + .zip(&colors) + .map(|(c_obj, c_str)| { + let ansi = c_obj.to_ansi_string(color_mode, ForegroundBackground::Foreground); + format!("{ansi}██\x1b[0m {c_str}") + }) + .join(" "); + println!("Colors ({}): {swatches}\n", colors.len()); + } + } + } else { + println!("Flag preview:"); + println!("(type hex colors to preview flag)\n\n\n"); + } + + println!("Press Enter to select this flag, or Esc to cancel."); + print!("> {input_text}"); + io::stdout().flush().context("failed to flush custom flag prompt")?; + + if let Some(h) = hint { + println!("\n{h}"); + } + + raw_mode + .enable() + .context("failed to enable raw mode for key input")?; + + let event = event::read().context("failed to read keyboard event")?; + let Event::Key(key) = event else { + continue; + }; + if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) { + continue; + } + + hint = None; + + match key.code { + KeyCode::Enter => { + let colors = parse_hex_colors(&input_text); + if !colors.is_empty() { + raw_mode + .disable() + .context("failed to disable raw mode after custom flag")?; + return Ok(colors.join(",")); + } else { + hint = Some("Please enter at least one valid hex color (e.g. ff0000)."); + } + } + KeyCode::Backspace => { + input_text.pop(); + } + KeyCode::Esc => { + raw_mode + .disable() + .context("failed to disable raw mode after custom flag cancel")?; + return Ok(Preset::Rainbow.as_ref().to_owned()); + } + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + raw_mode + .disable() + .context("failed to disable raw mode before interrupting")?; + println!(); + return Err(anyhow::anyhow!("interrupted by user")); + } + KeyCode::Char(c) + if !key.modifiers.contains(KeyModifiers::CONTROL) + && !key.modifiers.contains(KeyModifiers::ALT) => + { + input_text.push(c); + } + _ => {} + } + } +} + /// Creates config interactively. /// /// The config is automatically stored to file. @@ -367,10 +537,6 @@ fn create_config( .expect("`option_counter` should not overflow `u8`"); } - fn print_title_prompt(option_counter: NonZeroU8, prompt: &str) { - printc!("&a{option_counter}. {prompt}"); - } - ////////////////////////////// // 0. Check term size @@ -504,40 +670,6 @@ fn create_config( ////////////////////////////// // 3. Choose preset - struct RawModeGuard { - enabled: bool, - } - - impl RawModeGuard { - fn new() -> Result { - Ok(Self { enabled: false }) - } - - fn enable(&mut self) -> Result<()> { - if !self.enabled { - enable_raw_mode().context("failed to enable terminal raw mode")?; - self.enabled = true; - } - Ok(()) - } - - fn disable(&mut self) -> Result<()> { - if self.enabled { - disable_raw_mode().context("failed to disable terminal raw mode")?; - self.enabled = false; - } - Ok(()) - } - } - - impl Drop for RawModeGuard { - fn drop(&mut self) { - if self.enabled { - let _ = disable_raw_mode(); - } - } - } - // Create flag lines let mut flags = Vec::with_capacity(Preset::COUNT); let spacing = { @@ -571,12 +703,41 @@ fn create_config( spacing = usize::from(spacing) ); flags.push(( - preset.clone(), + preset.as_ref().to_owned(), [name, flag.clone(), flag.clone(), flag], preset.as_ref().to_ascii_lowercase(), + color_profile, )); } + let custom_profile = ColorProfile::from_hex_colors(vec![ + "#e06c75".to_string(), + "#e5c07b".to_string(), + "#98c379".to_string(), + "#56b6c2".to_string(), + "#61afef".to_string(), + "#c678dd".to_string(), + ]).expect("custom profile should be valid"); + let custom_flag = custom_profile + .color_text( + " ".repeat(usize::from(spacing)), + color_mode, + ForegroundBackground::Background, + false, + ) + .context("failed to color custom flag")?; + let custom_name = format!( + "{name:^spacing$}", + name = "custom", + spacing = usize::from(spacing) + ); + flags.push(( + "custom".to_string(), + [custom_name, custom_flag.clone(), custom_flag.clone(), custom_flag], + "custom".to_string(), + custom_profile, + )); + // Calculate flags per row let (flags_per_row, rows_per_page) = { let (Width(term_w), Height(term_h)) = terminal_size().context("failed to get term size")?; @@ -586,7 +747,7 @@ fn create_config( }; let flags_per_page = usize::from(flags_per_row) * usize::from(rows_per_page); - fn filter_flag_indices(query: &str, flags: &[(Preset, [String; 4], String)]) -> Vec { + fn filter_flag_indices(query: &str, flags: &[(String, [String; 4], String, ColorProfile)]) -> Vec { if query.is_empty() { return (0..flags.len()).collect(); } @@ -594,7 +755,7 @@ fn create_config( let mut matched = flags .iter() .enumerate() - .filter_map(|(idx, (_, _, preset_name))| { + .filter_map(|(idx, (_, _, preset_name, _))| { let position = preset_name.find(query)?; Some((idx, preset_name.starts_with(query), position)) }) @@ -728,14 +889,20 @@ fn create_config( resolved_presets.push(Preset::Rainbow.as_ref().to_owned()); } else { for part in parts.iter() { + let hex_colors = parse_hex_colors(part); + if !hex_colors.is_empty() { + resolved_presets.push(hex_colors.join(",")); + continue; + } + let selection = flags .iter() - .find(|(_, _, name)| name == part) - .map(|(preset, _, _)| preset.as_ref().to_owned()) + .find(|(_, _, name, _)| name == part) + .map(|(name, _, _, _)| name.clone()) .or_else(|| { // Fuzzy match each part let filtered = filter_flag_indices(part, &flags); - filtered.first().map(|&idx| flags[idx].0.as_ref().to_owned()) + filtered.first().map(|&idx| flags[idx].0.clone()) }); if let Some(p) = selection { @@ -786,6 +953,25 @@ fn create_config( .disable() .context("failed to disable raw mode after preset selection")?; + let mut resolved_custom_preset_names = Vec::new(); + for name in selected_preset_names { + if name == "custom" { + let custom_hex = prompt_custom_flag( + &title, + option_counter, + color_mode, + theme, + default_lightness, + usize::from(spacing), + debug_mode, + )?; + resolved_custom_preset_names.push(custom_hex); + } else { + resolved_custom_preset_names.push(name); + } + } + let selected_preset_names = resolved_custom_preset_names; + let selected_presets = if selected_preset_names.len() > 1 { PresetValue::Multiple(selected_preset_names.clone()) } else { @@ -798,7 +984,7 @@ fn create_config( let colors: Vec = first_name.split(',').map(|s| s.trim().to_owned()).collect(); build_hex_color_profile(&colors).expect("hex colors should be valid") } else { - flags.iter().find(|(_, _, name)| name == &first_name).map(|(p, _, _)| p.color_profile()).unwrap_or_else(|| Preset::Rainbow.color_profile()) + flags.iter().find(|(_, _, name, _)| name == &first_name).map(|(_, _, _, p)| p.clone()).unwrap_or_else(|| Preset::Rainbow.color_profile()) } }; @@ -808,7 +994,7 @@ fn create_config( let colors: Vec = name.split(',').map(|s| s.trim().to_owned()).collect(); build_hex_color_profile(&colors).expect("hex colors should be valid") } else { - flags.iter().find(|(_, _, n)| n == name).map(|(p, _, _)| p.color_profile()).unwrap_or_else(|| Preset::Rainbow.color_profile()) + flags.iter().find(|(_, _, n, _)| n == name).map(|(_, _, _, p)| p.clone()).unwrap_or_else(|| Preset::Rainbow.color_profile()) }; profile.with_lightness_adaptive(default_lightness, theme) .color_text(name, color_mode, ForegroundBackground::Foreground, false) @@ -1019,7 +1205,7 @@ fn create_config( let arrangements: IndexMap, NormalizedAsciiArt> = ds_arrangements.map(|(k, a)| (k.into(), a)).into(); - loop { + { clear_screen(Some(&title), color_mode, debug_mode).context("failed to clear screen")?; let asciis: Vec> = arrangements @@ -1082,8 +1268,6 @@ fn create_config( "Selected logo type", choice.as_ref(), ); - - break; } } diff --git a/hyfetch/main.py b/hyfetch/main.py index 20adda9d..23bfa8e9 100755 --- a/hyfetch/main.py +++ b/hyfetch/main.py @@ -7,6 +7,7 @@ import importlib.util import json import os import random +import re import sys import traceback from itertools import permutations, islice @@ -22,6 +23,22 @@ from .neofetch_util import * from .presets import PRESETS, ColorProfile +def parse_hex_colors(text: str) -> list[str]: + """ + Parse hex colors from text, allowing space or comma separation, + with or without '#' prefix, supporting 3 or 6 hex digits. + """ + tokens = re.split(r'[,;\s]+', text.strip()) + colors = [] + for tok in tokens: + if not tok: + continue + c = tok.lstrip('#') + if len(c) in (3, 6) and all(ch in '0123456789abcdefABCDEF' for ch in c): + colors.append(f'#{c}') + return colors + + def check_config(path) -> Config: """ @@ -138,6 +155,11 @@ def create_config() -> Config: flag = preset.color_text(' ' * spacing, foreground=False) flag_entries.append((name, [name.center(spacing), flag, flag, flag], name.lower())) + # Add custom flag option + custom_profile = build_hex_color_profile(['#e06c75', '#e5c07b', '#98c379', '#56b6c2', '#61afef', '#c678dd']) + custom_flag = custom_profile.set_light_dl_def(light_dark).color_text(' ' * spacing, foreground=False) + flag_entries.append(('custom', ['custom'.center(spacing), custom_flag, custom_flag, custom_flag], 'custom')) + # Calculate flags per row flags_per_row = max(1, term_size()[0] // (spacing + 2)) row_per_page = max(1, (term_size()[1] - 13) // 5) @@ -249,6 +271,12 @@ def create_config() -> Config: print(f"> {last_filter}") raise + if selection == 'custom': + return 'custom' + parsed = parse_hex_colors(selection) + if parsed: + return ','.join(parsed) + filtered_indices = filter_flag_indices(selection) if selection in PRESETS: return selection @@ -256,9 +284,87 @@ def create_config() -> Config: return flag_entries[filtered_indices[0]][0] return 'rainbow' - preset = select_preset_prompt_toolkit() + def prompt_custom_flag() -> str: + from prompt_toolkit import PromptSession + from prompt_toolkit.application.current import get_app + from prompt_toolkit.formatted_text import ANSI + from prompt_toolkit.key_binding import KeyBindings - _prs = PRESETS[preset] + last_input = '' + kb = KeyBindings() + + @kb.add('escape') + def _cancel(event): + event.app.exit(result='__cancel__') + + def build_custom_lines(raw_input: str) -> list[str]: + colors = parse_hex_colors(raw_input) + lines = [ + color(f"&a{option_counter}. Let's set up your custom flag!&r"), + "Enter colors separated by spaces or commas (e.g. ff0000 00ff00 0000ff or #f00 #0f0):", + "", + "Flag preview:", + ] + if colors: + try: + profile = build_hex_color_profile(colors) + preview = profile.set_light_dl_def(light_dark).color_text(' ' * spacing, foreground=False) + lines.append(preview) + lines.append(preview) + lines.append(preview) + swatches = ' '.join( + f"{c_obj.to_ansi(foreground=True)}██&r {c}" + for c_obj, c in zip(profile.colors, colors) + ) + lines.append(color(f"Colors ({len(colors)}): {swatches}")) + except Exception: + lines.extend(["(invalid colors)", "", "", ""]) + else: + lines.append("(type hex colors to preview flag)") + lines.append("") + lines.append("") + lines.append("") + lines.append("") + lines.append("Press Enter to select this flag, or Esc / Ctrl+C to cancel.") + return lines + + def prompt_message(): + nonlocal last_input + app = get_app() + raw_input = app.current_buffer.text + last_input = raw_input + lines = build_custom_lines(raw_input) + return ANSI('\n'.join(lines) + '\n> ') + + clear_screen(title) + session = PromptSession(key_bindings=kb) + while True: + try: + line = session.prompt(prompt_message).strip() + except KeyboardInterrupt: + clear_screen(title) + for l in build_custom_lines(last_input): + print(l) + print(f"> {last_input}") + raise + + if line == '__cancel__': + return 'rainbow' + + colors = parse_hex_colors(line) + if colors: + return ','.join(colors) + printc("&cPlease enter at least one valid hex color (e.g. ff0000).") + + preset = select_preset_prompt_toolkit() + if preset == 'custom': + preset = prompt_custom_flag() + + if '#' in preset: + colors = [s.strip() for s in preset.split(',')] + _prs = build_hex_color_profile(colors) + else: + _prs = PRESETS[preset] update_title('Selected flag', _prs.set_light_dl_def(light_dark).color_text(preset)) ############################# @@ -607,10 +713,10 @@ def run(): # Run try: - if config.custom_ascii_path: - asc = Path(config.custom_ascii_path).read_text("utf-8") - elif args.ascii_file: + if args.ascii_file: asc = Path(args.ascii_file).read_text("utf-8") + elif config.custom_ascii_path: + asc = Path(config.custom_ascii_path).read_text("utf-8") else: asc = get_distro_ascii() color_align: ColorAlignment = get_random_alignment(preset, asc) if (args.random_alignment or config.color_align.mode == "random") else config.color_align