[+] Custom flag support

This commit is contained in:
Azalea
2026-10-15 21:52:20 +00:00
parent ad6877da1f
commit 371c1356a9
2 changed files with 344 additions and 54 deletions

View File

@@ -309,6 +309,176 @@ fn det_bg() -> Result<Option<Srgb<u8>>, terminal_colorsaurus::Error> {
}
}
struct RawModeGuard {
enabled: bool,
}
impl RawModeGuard {
fn new() -> Result<Self> {
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<String> {
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<String> {
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<Self> {
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<usize> {
fn filter_flag_indices(query: &str, flags: &[(String, [String; 4], String, ColorProfile)]) -> Vec<usize> {
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<String> = 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<String> = 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<Cow<str>, 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<Vec<String>> = arrangements
@@ -1082,8 +1268,6 @@ fn create_config(
"Selected logo type",
choice.as_ref(),
);
break;
}
}

View File

@@ -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