diff --git a/Cargo.lock b/Cargo.lock index 7df8cc75..4506a714 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -226,6 +226,7 @@ dependencies = [ "tracing", "tracing-subscriber", "unicode-normalization", + "unicode-segmentation", ] [[package]] @@ -711,6 +712,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-segmentation" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" + [[package]] name = "valuable" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index b1d8f8b4..58b31d6a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,3 +34,4 @@ thiserror = { version = "1.0.61", default-features = false } tracing = { version = "0.1.40", default-features = false } tracing-subscriber = { version = "0.3.18", default-features = false } unicode-normalization = { version = "0.1.23", default-features = false } +unicode-segmentation = { version = "1.11.0", default-features = false } diff --git a/crates/hyfetch/Cargo.toml b/crates/hyfetch/Cargo.toml index 7e8545a9..4913b22d 100644 --- a/crates/hyfetch/Cargo.toml +++ b/crates/hyfetch/Cargo.toml @@ -29,6 +29,7 @@ tempfile = { workspace = true, features = [] } thiserror = { workspace = true, features = [] } tracing = { workspace = true, features = ["attributes", "std"] } tracing-subscriber = { workspace = true, features = ["ansi", "fmt", "smallvec", "std", "tracing-log"] } +unicode-segmentation = { workspace = true, features = [] } [build-dependencies] indexmap = { workspace = true, features = ["std"] } diff --git a/crates/hyfetch/src/models.rs b/crates/hyfetch/src/models.rs index 7751d2a5..10265421 100644 --- a/crates/hyfetch/src/models.rs +++ b/crates/hyfetch/src/models.rs @@ -14,7 +14,7 @@ pub struct Config { pub color_align: ColorAlignment, pub backend: Backend, #[serde(default)] - #[serde(with = "self::args_serde_with")] + #[serde(with = "self::args_serde")] pub args: Option>, pub distro: Option, pub pride_month_disable: bool, @@ -38,16 +38,15 @@ impl Config { } } -mod args_serde_with { +mod args_serde { use std::fmt; use serde::de::{self, value, Deserialize, Deserializer, SeqAccess, Visitor}; use serde::ser::Serializer; - pub(super) fn serialize( - value: &Option>, - serializer: S, - ) -> Result + type Value = Option>; + + pub(super) fn serialize(value: &Value, serializer: S) -> Result where S: Serializer, { @@ -57,7 +56,7 @@ mod args_serde_with { } } - pub(super) fn deserialize<'de, D>(deserializer: D) -> Result>, D::Error> + pub(super) fn deserialize<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { @@ -88,7 +87,7 @@ mod args_serde_with { } impl<'de> Visitor<'de> for OptionVisitor { - type Value = Option>; + type Value = Value; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { formatter.write_str("option") diff --git a/crates/hyfetch/src/neofetch_util.rs b/crates/hyfetch/src/neofetch_util.rs index 11ddd5f5..bec88a84 100644 --- a/crates/hyfetch/src/neofetch_util.rs +++ b/crates/hyfetch/src/neofetch_util.rs @@ -6,7 +6,7 @@ use std::os::unix::process::ExitStatusExt as _; use std::path::{Path, PathBuf}; use std::process::{Command, ExitStatus}; use std::sync::OnceLock; -use std::{env, fmt, iter}; +use std::{env, fmt}; use aho_corasick::AhoCorasick; use anyhow::{anyhow, Context, Result}; @@ -37,6 +37,7 @@ pub enum ColorAlignment { }, Custom { #[serde(rename = "custom_colors")] + #[serde(deserialize_with = "crate::utils::index_map_serde::deserialize")] colors: IndexMap, }, } @@ -83,11 +84,14 @@ impl ColorAlignment { // Add new colors let asc = match self { Self::Horizontal { .. } => { - let length = lines.len(); - let length: u8 = length.try_into().expect("`length` should fit in `u8`"); - let ColorProfile { colors } = color_profile - .with_length(length) - .context("failed to spread color profile to length")?; + let ColorProfile { colors } = { + let length = lines.len(); + let length: u8 = + length.try_into().expect("`length` should fit in `u8`"); + color_profile + .with_length(length) + .context("failed to spread color profile to length")? + }; let mut asc = String::new(); for (i, line) in lines.into_iter().enumerate() { let line = line.replace( @@ -119,21 +123,91 @@ impl ColorAlignment { let ac = NEOFETCH_COLORS_AC .get_or_init(|| AhoCorasick::new(NEOFETCH_COLOR_PATTERNS).unwrap()); const N: usize = NEOFETCH_COLOR_PATTERNS.len(); - let replacements: [&str; N] = iter::repeat("") - .take(N) - .collect::>() - .try_into() - .unwrap(); - ac.replace_all(&asc, &replacements) + const REPLACEMENTS: [&str; N] = [""; N]; + ac.replace_all(&asc, &REPLACEMENTS) }; asc }, Self::Horizontal { fore_back: None } | Self::Vertical { fore_back: None } => { - todo!() + // Remove existing colors + let asc = { + let ac = NEOFETCH_COLORS_AC + .get_or_init(|| AhoCorasick::new(NEOFETCH_COLOR_PATTERNS).unwrap()); + const N: usize = NEOFETCH_COLOR_PATTERNS.len(); + const REPLACEMENTS: [&str; N] = [""; N]; + ac.replace_all(&asc, &REPLACEMENTS) + }; + + let lines: Vec<_> = asc.split('\n').collect(); + + // Add new colors + match self { + Self::Horizontal { .. } => { + let ColorProfile { colors } = { + let length = lines.len(); + let length: u8 = + length.try_into().expect("`length` should fit in `u8`"); + color_profile + .with_length(length) + .context("failed to spread color profile to length")? + }; + let mut asc = String::new(); + for (i, line) in lines.into_iter().enumerate() { + asc.push_str( + &colors[i] + .to_ansi_string(color_mode, ForegroundBackground::Foreground), + ); + asc.push_str(line); + asc.push_str(&reset); + asc.push('\n'); + } + asc + }, + Self::Vertical { .. } => { + let mut asc = String::new(); + for line in lines { + let line = color_profile + .color_text( + line, + color_mode, + ForegroundBackground::Foreground, + false, + ) + .context("failed to color text using color profile")?; + asc.push_str(&line); + asc.push_str(&reset); + asc.push('\n'); + } + asc + }, + _ => { + unreachable!(); + }, + } }, - Self::Custom { colors } => { - todo!() + Self::Custom { + colors: custom_colors, + } => { + let ColorProfile { colors } = color_profile.unique_colors(); + + // Apply colors + let asc = { + let ac = NEOFETCH_COLORS_AC + .get_or_init(|| AhoCorasick::new(NEOFETCH_COLOR_PATTERNS).unwrap()); + const N: usize = NEOFETCH_COLOR_PATTERNS.len(); + let mut replacements = vec![Cow::from(""); N]; + for (&ai, &pi) in custom_colors { + let ai: u8 = ai.into(); + let pi: u8 = pi.into(); + replacements[ai as usize - 1] = colors[pi as usize] + .to_ansi_string(color_mode, ForegroundBackground::Foreground) + .into(); + } + ac.replace_all(&asc, &replacements) + }; + + asc }, }; @@ -193,11 +267,22 @@ where }; debug!(%distro, "distro name"); + // Try new codegen-based detection method if let Some(distro) = Distro::detect(&distro) { return Ok(normalize_ascii(distro.ascii_art())); } - todo!() + debug!(%distro, "could not find a match for distro; falling back to neofetch"); + + // Old detection method that calls neofetch + let asc = run_neofetch_command_piped(&["print_ascii", "--ascii_distro", distro.as_ref()]) + .context("failed to get ascii art from neofetch")?; + + // Unescape backslashes here because backslashes are escaped in neofetch for + // printf + let asc = asc.replace(r"\\", r"\"); + + Ok(normalize_ascii(asc)) } #[tracing::instrument(level = "debug")] @@ -231,12 +316,8 @@ where let ac = NEOFETCH_COLORS_AC.get_or_init(|| AhoCorasick::new(NEOFETCH_COLOR_PATTERNS).unwrap()); const N: usize = NEOFETCH_COLOR_PATTERNS.len(); - let replacements: [&str; N] = iter::repeat("") - .take(N) - .collect::>() - .try_into() - .unwrap(); - ac.replace_all(asc, &replacements) + const REPLACEMENTS: [&str; N] = [""; N]; + ac.replace_all(asc, &REPLACEMENTS) }; let Some(width) = asc.split('\n').map(|line| line.len()).max() else { diff --git a/crates/hyfetch/src/presets.rs b/crates/hyfetch/src/presets.rs index 9770e076..40cec46a 100644 --- a/crates/hyfetch/src/presets.rs +++ b/crates/hyfetch/src/presets.rs @@ -7,9 +7,10 @@ use palette::num::ClampAssign; use palette::{Hsl, IntoColorMut, LinSrgb, Srgb}; use serde::{Deserialize, Serialize}; use strum::{EnumString, VariantNames}; +use unicode_segmentation::UnicodeSegmentation; -use crate::color_util::Lightness; -use crate::types::LightDark; +use crate::color_util::{ForegroundBackground, Lightness, ToAnsiString}; +use crate::types::{AnsiMode, LightDark}; #[derive(Copy, Clone, Hash, Debug, Deserialize, EnumString, Serialize, VariantNames)] #[serde(rename_all = "kebab-case")] @@ -290,10 +291,10 @@ impl Preset { }, // sourced from https://www.flagcolorcodes.com/intergender - Self::Intergender => ColorProfile::from_hex_colors( - // todo: use weighted spacing - vec!["#900DC2", "#900DC2", "#FFE54F", "#900DC2", "#900DC2"], - ), + Self::Intergender => { + ColorProfile::from_hex_colors(vec!["#900DC2", "#FFE54F", "#900DC2"]) + .and_then(|c| c.with_weights(vec![2, 1, 2])) + }, Self::Lesbian => ColorProfile::from_hex_colors(vec![ "#D62800", "#FF9B56", "#FFFFFF", "#D462A6", "#A40062", @@ -440,7 +441,7 @@ impl ColorProfile { // How many copies of each color should be displayed at least? let repeats = (length as f32 / orig_len as f32).floor() as usize; let repeats: u8 = repeats.try_into().expect("`repeats` should fit in `u8`"); - let mut weights: Vec = iter::repeat(repeats).take(orig_len as usize).collect(); + let mut weights = vec![repeats; orig_len as usize]; // How many extra spaces left? let mut extras = length % orig_len; @@ -467,6 +468,50 @@ impl ColorProfile { self.with_weights(weights) } + /// Colors a text. + /// + /// # Arguments + /// + /// * `foreground_background` - Whether the color is shown on the foreground + /// text or the background block + /// * `space_only` - Whether to only color spaces + pub fn color_text( + &self, + txt: S, + color_mode: AnsiMode, + foreground_background: ForegroundBackground, + space_only: bool, + ) -> Result + where + S: AsRef, + { + let txt = txt.as_ref(); + + let ColorProfile { colors } = { + let length = txt.len(); + let length: u8 = length.try_into().expect("`length` should fit in `u8`"); + self.with_length(length) + .context("failed to spread color profile to length")? + }; + + let mut buf = String::new(); + let txt: Vec<&str> = txt.graphemes(true).collect(); + for (i, &gr) in txt.iter().enumerate() { + if space_only && gr != " " { + if i > 0 && txt[i - 1] == " " { + buf.push_str("\x1b[39;49m"); + } + buf.push_str(gr); + } else { + buf.push_str(&colors[i].to_ansi_string(color_mode, foreground_background)); + buf.push_str(gr); + } + } + + buf.push_str("\x1b[39;49m"); + Ok(buf) + } + /// Creates a new color profile, with the colors lightened by a multiplier. pub fn lighten(&self, multiplier: f32) -> Self { let mut rgb_f32_colors: Vec = diff --git a/crates/hyfetch/src/utils.rs b/crates/hyfetch/src/utils.rs index b4571eda..ae061fe5 100644 --- a/crates/hyfetch/src/utils.rs +++ b/crates/hyfetch/src/utils.rs @@ -10,3 +10,97 @@ pub fn get_cache_path() -> Result { .to_owned(); Ok(path) } + +pub(crate) mod index_map_serde { + use std::fmt; + use std::fmt::Display; + use std::hash::Hash; + use std::marker::PhantomData; + use std::str::FromStr; + + use indexmap::IndexMap; + use serde::de::{self, DeserializeSeed, MapAccess, Visitor}; + use serde::{Deserialize, Deserializer}; + + pub(crate) fn deserialize<'de, D, K, V>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + K: Eq + Hash + FromStr, + K::Err: Display, + V: Deserialize<'de>, + { + struct KeySeed { + k: PhantomData, + } + + impl<'de, K> DeserializeSeed<'de> for KeySeed + where + K: FromStr, + K::Err: Display, + { + type Value = K; + + fn deserialize(self, deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_str(self) + } + } + + impl<'de, K> Visitor<'de> for KeySeed + where + K: FromStr, + K::Err: Display, + { + type Value = K; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a string") + } + + fn visit_str(self, s: &str) -> Result + where + E: de::Error, + { + K::from_str(s).map_err(de::Error::custom) + } + } + + struct MapVisitor { + k: PhantomData, + v: PhantomData, + } + + impl<'de, K, V> Visitor<'de> for MapVisitor + where + K: Eq + Hash + FromStr, + K::Err: Display, + V: Deserialize<'de>, + { + type Value = IndexMap; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a map") + } + + fn visit_map(self, mut input: A) -> Result + where + A: MapAccess<'de>, + { + let mut map = IndexMap::new(); + while let Some((k, v)) = + input.next_entry_seed(KeySeed { k: PhantomData }, PhantomData)? + { + map.insert(k, v); + } + Ok(map) + } + } + + deserializer.deserialize_map(MapVisitor { + k: PhantomData, + v: PhantomData, + }) + } +}