From be70233b0368b3588a87cd05102c959ff7785f24 Mon Sep 17 00:00:00 2001 From: Teoh Han Hui Date: Sun, 7 Jul 2024 04:39:41 +0800 Subject: [PATCH] Implement recolored ascii output --- Cargo.lock | 155 ++++++++++++--- Cargo.toml | 7 +- crates/hyfetch/Cargo.toml | 12 +- crates/hyfetch/src/bin/hyfetch.rs | 6 +- crates/hyfetch/src/color_util.rs | 270 ++++++++++++++++++++++---- crates/hyfetch/src/models.rs | 8 +- crates/hyfetch/src/neofetch_util.rs | 287 ++++++++++++++++++++++++---- crates/hyfetch/src/presets.rs | 67 +++++-- 8 files changed, 694 insertions(+), 118 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 12bb9bb7..7df8cc75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,12 @@ dependencies = [ "libc", ] +[[package]] +name = "ansi_colours" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1558bd2075d341b9ca698ec8eb6fcc55a746b1fc4255585aad5b141d918a80" + [[package]] name = "anyhow" version = "1.0.86" @@ -114,26 +120,6 @@ dependencies = [ "serde", ] -[[package]] -name = "derive_more" -version = "1.0.0-beta.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7abbfc297053be59290e3152f8cbcd52c8642e0728b69ee187d991d4c1af08d" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "1.0.0-beta.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bba3e9872d7c58ce7ef0fcf1844fcc3e23ef2a58377b50df35dd98e42a5726e" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "directories" version = "5.0.1" @@ -152,7 +138,16 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys", + "windows-sys 0.48.0", +] + +[[package]] +name = "enable-ansi-support" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa4ff3ae2a9aa54bf7ee0983e59303224de742818c1822d89f07da9856d9bc60" +dependencies = [ + "windows-sys 0.42.0", ] [[package]] @@ -161,12 +156,28 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "errno" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "fast-srgb8" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" +[[package]] +name = "fastrand" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" + [[package]] name = "getrandom" version = "0.2.15" @@ -194,12 +205,14 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" name = "hyfetch" version = "1.4.11" dependencies = [ + "aho-corasick", + "ansi_colours", "anyhow", "bpaf", "chrono", "deranged", - "derive_more", "directories", + "enable-ansi-support", "indexmap", "palette", "regex", @@ -208,6 +221,7 @@ dependencies = [ "serde_path_to_error", "shell-words", "strum", + "tempfile", "thiserror", "tracing", "tracing-subscriber", @@ -291,6 +305,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" + [[package]] name = "log" version = "0.4.21" @@ -433,6 +453,19 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" +[[package]] +name = "rustix" +version = "0.38.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + [[package]] name = "rustversion" version = "1.0.17" @@ -549,6 +582,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" +dependencies = [ + "cfg-if", + "fastrand", + "rustix", + "windows-sys 0.52.0", +] + [[package]] name = "thiserror" version = "1.0.61" @@ -763,6 +808,21 @@ dependencies = [ "windows-targets 0.52.5", ] +[[package]] +name = "windows-sys" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -772,6 +832,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.5", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -803,6 +872,12 @@ dependencies = [ "windows_x86_64_msvc 0.52.5", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -815,6 +890,12 @@ version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -827,6 +908,12 @@ version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -845,6 +932,12 @@ version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -857,6 +950,12 @@ version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -869,6 +968,12 @@ version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -881,6 +986,12 @@ version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" diff --git a/Cargo.toml b/Cargo.toml index dcd18736..b1d8f8b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,23 +12,24 @@ repository = "https://github.com/hykilpikonna/hyfetch" license = "MIT" [workspace.dependencies] +aho-corasick = { version = "1.1.3", default-features = false } ansi_colours = { version = "1.2.2", default-features = false } +# anstream = { version = "0.6.14", default-features = false } anyhow = { version = "1.0.86", default-features = false } bpaf = { version = "0.9.12", default-features = false } -bytemuck = { version = "1.16.1", default-features = false } chrono = { version = "0.4.38", default-features = false } deranged = { version = "0.3.11", default-features = false } -derive_more = { version = "1.0.0-beta.6", default-features = false } directories = { version = "5.0.1", default-features = false } +enable-ansi-support = { version = "0.2.1", default-features = false } indexmap = { version = "2.2.6", default-features = false } palette = { version = "0.7.6", default-features = false } regex = { version = "1.10.5", default-features = false } -rgb = { version = "0.8.37", default-features = false } serde = { version = "1.0.203", default-features = false } serde_json = { version = "1.0.118", default-features = false } serde_path_to_error = { version = "0.1.16", default-features = false } shell-words = { version = "1.1.0", default-features = false } strum = { version = "0.26.3", default-features = false } +tempfile = { version = "3.10.1", default-features = false } 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 } diff --git a/crates/hyfetch/Cargo.toml b/crates/hyfetch/Cargo.toml index 1fddfb80..7e8545a9 100644 --- a/crates/hyfetch/Cargo.toml +++ b/crates/hyfetch/Cargo.toml @@ -10,23 +10,22 @@ license = { workspace = true } default-run = "hyfetch" [dependencies] -# ansi_colours = { workspace = true, features = ["rgb"] } +aho-corasick = { workspace = true, features = ["perf-literal", "std"] } +ansi_colours = { workspace = true, features = [] } +# anstream = { workspace = true, features = ["auto"] } anyhow = { workspace = true, features = ["std"] } bpaf = { workspace = true, features = [] } -# bytemuck = { workspace = true, features = [] } chrono = { workspace = true, features = ["clock", "std"] } deranged = { workspace = true, features = ["serde", "std"] } -derive_more = { workspace = true, features = ["from", "from_str", "into", "std"] } directories = { workspace = true, features = [] } indexmap = { workspace = true, features = ["serde", "std"] } palette = { workspace = true, features = ["std"] } -regex = { workspace = true, features = ["perf", "std", "unicode"] } -# rgb = { workspace = true, features = [] } serde = { workspace = true, features = ["derive", "std"] } serde_json = { workspace = true, features = ["std"] } serde_path_to_error = { workspace = true, features = [] } shell-words = { workspace = true, features = ["std"] } strum = { workspace = true, features = ["derive", "std"] } +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"] } @@ -36,6 +35,9 @@ indexmap = { workspace = true, features = ["std"] } regex = { workspace = true, features = ["perf", "std", "unicode"] } unicode-normalization = { workspace = true, features = ["std"] } +[target.'cfg(windows)'.dependencies] +enable-ansi-support = { workspace = true, features = [] } + [features] default = ["autocomplete", "color"] autocomplete = ["bpaf/autocomplete"] diff --git a/crates/hyfetch/src/bin/hyfetch.rs b/crates/hyfetch/src/bin/hyfetch.rs index 8bbf69d7..ceb38e2a 100644 --- a/crates/hyfetch/src/bin/hyfetch.rs +++ b/crates/hyfetch/src/bin/hyfetch.rs @@ -13,6 +13,9 @@ use hyfetch::utils::get_cache_path; use tracing::debug; fn main() -> Result<()> { + #[cfg(windows)] + enable_ansi_support::enable_ansi_support(); + let options = options().run(); init_tracing_subsriber(options.debug).context("failed to init tracing subscriber")?; @@ -97,7 +100,8 @@ fn main() -> Result<()> { }; let asc = config .color_align - .recolor_ascii(asc, color_profile, color_mode, config.light_dark); + .recolor_ascii(asc, color_profile, color_mode, config.light_dark) + .context("failed to recolor ascii")?; neofetch_util::run(asc, backend, args).context("failed to run")?; if options.ask_exit { diff --git a/crates/hyfetch/src/color_util.rs b/crates/hyfetch/src/color_util.rs index e63c2948..12ddfd30 100644 --- a/crates/hyfetch/src/color_util.rs +++ b/crates/hyfetch/src/color_util.rs @@ -1,16 +1,63 @@ -use std::num::ParseFloatError; +use std::num::{ParseFloatError, ParseIntError}; +use std::str::FromStr; +use std::sync::OnceLock; -use anyhow::Result; +use aho_corasick::AhoCorasick; +use ansi_colours::AsRGB; +use anyhow::{anyhow, Context, Result}; use deranged::RangedU8; -use derive_more::{From, FromStr, Into}; +use palette::Srgb; use serde::{Deserialize, Serialize}; use thiserror::Error; +use crate::types::AnsiMode; + +const MINECRAFT_COLORS: [(&str, &str); 30] = [ + // Minecraft formatting codes + // ========================== + ("&0", "\x1b[38;5;0m"), + ("&1", "\x1b[38;5;4m"), + ("&2", "\x1b[38;5;2m"), + ("&3", "\x1b[38;5;6m"), + ("&4", "\x1b[38;5;1m"), + ("&5", "\x1b[38;5;5m"), + ("&6", "\x1b[38;5;3m"), + ("&7", "\x1b[38;5;7m"), + ("&8", "\x1b[38;5;8m"), + ("&9", "\x1b[38;5;12m"), + ("&a", "\x1b[38;5;10m"), + ("&b", "\x1b[38;5;14m"), + ("&c", "\x1b[38;5;9m"), + ("&d", "\x1b[38;5;13m"), + ("&e", "\x1b[38;5;11m"), + ("&f", "\x1b[38;5;15m"), + ("&l", "\x1b[1m"), // Enable bold text + ("&o", "\x1b[3m"), // Enable italic text + ("&n", "\x1b[4m"), // Enable underlined text + ("&k", "\x1b[8m"), // Enable hidden text + ("&m", "\x1b[9m"), // Enable strikethrough text + ("&r", "\x1b[0m"), // Reset everything + // Extended codes (not officially in Minecraft) + // ============================================ + ("&-", "\n"), // Line break + ("&~", "\x1b[39m"), // Reset text color + ("&*", "\x1b[49m"), // Reset background color + ("&L", "\x1b[22m"), // Disable bold text + ("&O", "\x1b[23m"), // Disable italic text + ("&N", "\x1b[24m"), // Disable underlined text + ("&K", "\x1b[28m"), // Disable hidden text + ("&M", "\x1b[29m"), // Disable strikethrough text +]; +const RGB_COLOR_PATTERNS: [&str; 2] = ["&gf(", "&gb("]; + +static MINECRAFT_COLORS_AC: OnceLock<(AhoCorasick, Box<[&str; 30]>)> = OnceLock::new(); +static RGB_COLORS_AC: OnceLock = OnceLock::new(); + /// Represents the lightness component in HSL. /// /// The range of valid values is /// `(`[`Lightness::MIN`]`..=`[`Lightness::MAX`]`)`. -#[derive(Copy, Clone, PartialEq, PartialOrd, Debug, Deserialize, Into, Serialize)] +#[derive(Copy, Clone, PartialEq, PartialOrd, Debug, Deserialize, Serialize)] pub struct Lightness(f32); #[derive(Debug, Error)] @@ -37,21 +84,7 @@ pub enum ParseLightnessError { /// The range of valid values as supported in neofetch is /// `(`[`NeofetchAsciiIndexedColor::MIN`]`.. /// =`[`NeofetchAsciiIndexedColor::MAX`]`)`. -#[derive( - Copy, - Clone, - Eq, - PartialEq, - Ord, - PartialOrd, - Hash, - Debug, - Deserialize, - From, - FromStr, - Into, - Serialize, -)] +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] pub struct NeofetchAsciiIndexedColor( RangedU8<{ NeofetchAsciiIndexedColor::MIN }, { NeofetchAsciiIndexedColor::MAX }>, ); @@ -61,22 +94,21 @@ pub struct NeofetchAsciiIndexedColor( /// /// The range of valid values depends on the number of unique colors in a /// certain preset. -#[derive( - Copy, - Clone, - Eq, - PartialEq, - Ord, - PartialOrd, - Hash, - Debug, - Deserialize, - From, - FromStr, - Into, - Serialize, -)] -pub struct PresetIndexedColor(usize); +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)] +pub struct PresetIndexedColor(u8); + +/// Whether the color is for foreground text or background color. +#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)] +pub enum ForegroundBackground { + Foreground, + Background, +} + +pub trait ToAnsiString { + /// Converts RGB to ANSI escape code. + fn to_ansi_string(&self, mode: AnsiMode, foreground_background: ForegroundBackground) + -> String; +} impl Lightness { const MAX: f32 = 1.0f32; @@ -107,7 +139,175 @@ impl FromStr for Lightness { } } +impl From for f32 { + fn from(value: Lightness) -> Self { + value.0 + } +} + impl NeofetchAsciiIndexedColor { const MAX: u8 = 6; const MIN: u8 = 1; } + +impl TryFrom for NeofetchAsciiIndexedColor { + type Error = deranged::TryFromIntError; + + fn try_from(value: u8) -> Result { + Ok(Self(value.try_into()?)) + } +} + +impl FromStr for NeofetchAsciiIndexedColor { + type Err = deranged::ParseIntError; + + fn from_str(s: &str) -> Result { + Ok(Self(s.parse()?)) + } +} + +impl From for u8 { + fn from(value: NeofetchAsciiIndexedColor) -> Self { + value.0.get() + } +} + +impl From for PresetIndexedColor { + fn from(value: u8) -> Self { + Self(value) + } +} + +impl FromStr for PresetIndexedColor { + type Err = ParseIntError; + + fn from_str(s: &str) -> Result { + Ok(Self(s.parse()?)) + } +} + +impl From for u8 { + fn from(value: PresetIndexedColor) -> Self { + value.0 + } +} + +impl ToAnsiString for Srgb { + fn to_ansi_string( + &self, + mode: AnsiMode, + foreground_background: ForegroundBackground, + ) -> String { + let c: u8 = match foreground_background { + ForegroundBackground::Foreground => 38, + ForegroundBackground::Background => 48, + }; + match mode { + AnsiMode::Rgb => { + let [r, g, b]: [u8; 3] = (*self).into(); + format!("\x1b[{c};2;{r};{g};{b}m") + }, + AnsiMode::Ansi256 => { + let rgb: [u8; 3] = (*self).into(); + let indexed = rgb.to_ansi256(); + format!("\x1b[{c};5;{indexed}m") + }, + } + } +} + +/// Replaces extended minecraft color codes in message. +/// +/// Returns message with escape codes. +pub fn color(msg: S, mode: AnsiMode) -> Result +where + S: AsRef, +{ + let msg = msg.as_ref(); + + let msg = { + let (ac, escape_codes) = MINECRAFT_COLORS_AC.get_or_init(|| { + let (color_codes, escape_codes): (Vec<_>, Vec<_>) = + MINECRAFT_COLORS.into_iter().unzip(); + let ac = AhoCorasick::new(color_codes).unwrap(); + ( + ac, + escape_codes.try_into().expect( + "`MINECRAFT_COLORS` should have the same number of elements as \ + `MINECRAFT_COLORS_AC.get_or_init(...).1`", + ), + ) + }); + ac.replace_all(msg, &escape_codes[..]) + }; + + let ac = RGB_COLORS_AC.get_or_init(|| AhoCorasick::new(RGB_COLOR_PATTERNS).unwrap()); + let mut dst = String::new(); + let mut ret_err = None; + ac.replace_all_with(&msg, &mut dst, |m, _, dst| { + let start = m.end(); + let end = msg[start..] + .find(')') + .ok_or_else(|| anyhow!("missing closing brace for color code")); + let end = match end { + Ok(end) => end, + Err(err) => { + ret_err = Some(err); + return false; + }, + }; + let code = &msg[start..end]; + let foreground_background = if m.pattern().as_usize() == 0 { + ForegroundBackground::Foreground + } else { + ForegroundBackground::Background + }; + + let rgb: Srgb = if code.starts_with('#') { + let rgb = code.parse().context("failed to parse hex color"); + match rgb { + Ok(rgb) => rgb, + Err(err) => { + ret_err = Some(err); + return false; + }, + } + } else { + let rgb: Result<[&str; 3], _> = code + .split(&[',', ';', ' ']) + .filter(|x| x.is_empty()) + .collect::>() + .try_into() + .map_err(|_| anyhow!("wrong number of rgb components")); + let rgb = match rgb { + Ok(rgb) => rgb, + Err(err) => { + ret_err = Some(err); + return false; + }, + }; + let rgb = rgb + .into_iter() + .map(u8::from_str) + .collect::, _>>() + .context("failed to parse rgb components"); + let rgb: [u8; 3] = match rgb { + Ok(rgb) => rgb.try_into().unwrap(), + Err(err) => { + ret_err = Some(err); + return false; + }, + }; + rgb.into() + }; + + dst.push_str(&rgb.to_ansi_string(mode, foreground_background)); + + true + }); + if let Some(err) = ret_err { + Err(err)?; + } + + Ok(dst) +} diff --git a/crates/hyfetch/src/models.rs b/crates/hyfetch/src/models.rs index 6fd745b8..7751d2a5 100644 --- a/crates/hyfetch/src/models.rs +++ b/crates/hyfetch/src/models.rs @@ -23,8 +23,12 @@ pub struct Config { impl Config { pub fn default_lightness(term: &LightDark) -> Lightness { match term { - LightDark::Dark => Lightness::new(0.65).unwrap(), - LightDark::Light => Lightness::new(0.4).unwrap(), + LightDark::Dark => { + Lightness::new(0.65).expect("default lightness should not be invalid") + }, + LightDark::Light => { + Lightness::new(0.4).expect("default lightness should not be invalid") + }, } } diff --git a/crates/hyfetch/src/neofetch_util.rs b/crates/hyfetch/src/neofetch_util.rs index 979d2888..11ddd5f5 100644 --- a/crates/hyfetch/src/neofetch_util.rs +++ b/crates/hyfetch/src/neofetch_util.rs @@ -1,25 +1,29 @@ use std::borrow::Cow; use std::ffi::OsStr; +use std::io::Write; #[cfg(unix)] use std::os::unix::process::ExitStatusExt as _; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, ExitStatus}; use std::sync::OnceLock; -use std::{env, fmt}; +use std::{env, fmt, iter}; +use aho_corasick::AhoCorasick; use anyhow::{anyhow, Context, Result}; use indexmap::IndexMap; -use regex::Regex; use serde::{Deserialize, Serialize}; +use tempfile::NamedTempFile; use tracing::debug; -use crate::color_util::{NeofetchAsciiIndexedColor, PresetIndexedColor}; +use crate::color_util::{ + color, ForegroundBackground, NeofetchAsciiIndexedColor, PresetIndexedColor, ToAnsiString, +}; use crate::distros::Distro; use crate::presets::ColorProfile; use crate::types::{AnsiMode, Backend, LightDark}; -const NEOFETCH_COLOR_PATTERN: &str = r"\$\{c[0-6]\}"; -static NEOFETCH_COLOR_RE: OnceLock = OnceLock::new(); +const NEOFETCH_COLOR_PATTERNS: [&str; 6] = ["${c1}", "${c2}", "${c3}", "${c4}", "${c5}", "${c6}"]; +static NEOFETCH_COLORS_AC: OnceLock = OnceLock::new(); #[derive(Clone, Eq, PartialEq, Debug, Deserialize, Serialize)] #[serde(tag = "mode")] @@ -39,14 +43,101 @@ pub enum ColorAlignment { impl ColorAlignment { /// Uses the color alignment to recolor an ascii art. + #[tracing::instrument(level = "debug")] pub fn recolor_ascii( &self, asc: String, color_profile: ColorProfile, color_mode: AnsiMode, term: LightDark, - ) -> String { - todo!() + ) -> Result { + let asc = fill_starting(asc).context("failed to fill in starting neofetch color codes")?; + + let reset = color("&~&*", color_mode).expect("color reset should not be invalid"); + + let asc = match self { + Self::Horizontal { + fore_back: Some((fore, back)), + } + | Self::Vertical { + fore_back: Some((fore, back)), + } => { + let fore: u8 = (*fore).into(); + let back: u8 = (*back).into(); + + // Replace foreground colors + let asc = asc.replace( + &format!("${{c{fore}}}"), + &color( + match term { + LightDark::Light => "&0", + LightDark::Dark => "&f", + }, + color_mode, + ) + .expect("foreground color should not be invalid"), + ); + + let lines: Vec<_> = asc.split('\n').collect(); + + // 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 mut asc = String::new(); + for (i, line) in lines.into_iter().enumerate() { + let line = line.replace( + &format!("${{c{back}}}"), + &colors[i].to_ansi_string(color_mode, { + // note: this is "background" in the ascii art, but foreground + // text in terminal + ForegroundBackground::Foreground + }), + ); + asc.push_str(&line); + asc.push_str(&reset); + asc.push('\n'); + } + asc + }, + Self::Vertical { .. } => { + unimplemented!( + "vertical color alignment with fore and back colors not implemented" + ); + }, + _ => { + unreachable!(); + }, + }; + + // 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(); + let replacements: [&str; N] = iter::repeat("") + .take(N) + .collect::>() + .try_into() + .unwrap(); + ac.replace_all(&asc, &replacements) + }; + + asc + }, + Self::Horizontal { fore_back: None } | Self::Vertical { fore_back: None } => { + todo!() + }, + Self::Custom { colors } => { + todo!() + }, + }; + + Ok(asc) } } @@ -109,8 +200,24 @@ where todo!() } +#[tracing::instrument(level = "debug")] pub fn run(asc: String, backend: Backend, args: Option<&Vec>) -> Result<()> { - todo!() + match backend { + Backend::Neofetch => { + run_neofetch(asc, args).context("failed to run neofetch")?; + }, + Backend::Fastfetch => { + todo!(); + }, + Backend::FastfetchOld => { + todo!(); + }, + Backend::Qwqfetch => { + todo!(); + }, + } + + Ok(()) } /// Gets distro ascii width and height, ignoring color code. @@ -120,18 +227,26 @@ where { let asc = asc.as_ref(); - let Some(width) = NEOFETCH_COLOR_RE - .get_or_init(|| Regex::new(NEOFETCH_COLOR_PATTERN).unwrap()) - .replace_all(asc, "") - .split('\n') - .map(|line| line.len()) - .max() - else { + let asc = { + 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) + }; + + let Some(width) = asc.split('\n').map(|line| line.len()).max() else { unreachable!(); }; + let width: u8 = width.try_into().expect("`width` should fit in `u8`"); let height = asc.split('\n').count(); + let height: u8 = height.try_into().expect("`height` should fit in `u8`"); - (width as u8, height as u8) + (width, height) } /// Makes sure every line are the same width. @@ -146,13 +261,69 @@ where let mut buf = String::new(); for line in asc.split('\n') { let (line_w, _) = ascii_size(line); + buf.push_str(line); let pad = " ".repeat((w - line_w) as usize); - buf.push_str(&format!("{line}{pad}\n")) + buf.push_str(&pad); + buf.push('\n'); } buf } +/// Fills the missing starting placeholders. +/// +/// e.g. `"${c1}...\n..."` -> `"${c1}...\n${c1}..."` +fn fill_starting(asc: S) -> Result +where + S: AsRef, +{ + let asc = asc.as_ref(); + + let ac = NEOFETCH_COLORS_AC.get_or_init(|| AhoCorasick::new(NEOFETCH_COLOR_PATTERNS).unwrap()); + + let mut new = String::new(); + let mut last = None; + for line in asc.split('\n') { + let mut matches = ac.find_iter(line).peekable(); + + match matches.peek() { + Some(m) if m.start() == 0 => { + // line starts with neofetch color code, do nothing + }, + _ => { + new.push_str(last.ok_or_else(|| { + anyhow!("failed to find neofetch color code from a previous line") + })?); + }, + } + new.push_str(line); + new.push('\n'); + + // Get the last placeholder for the next line + if let Some(m) = matches.last() { + last = Some(&line[m.span()]) + } + } + + Ok(new) +} + +/// Runs neofetch command. +#[tracing::instrument(level = "debug")] +fn run_neofetch_command(args: &[S]) -> Result<()> +where + S: AsRef + fmt::Debug, +{ + let mut command = make_neofetch_command(args).context("failed to make neofetch command")?; + + let status = command + .status() + .context("failed to execute neofetch command as child process")?; + process_command_status(&status).context("neofetch command exited with error")?; + + Ok(()) +} + /// Runs neofetch command, returning the piped stdout output. #[tracing::instrument(level = "debug")] fn run_neofetch_command_piped(args: &[S]) -> Result @@ -165,26 +336,7 @@ where .output() .context("failed to execute neofetch as child process")?; debug!(?output, "neofetch output"); - - if !output.status.success() { - let err = if let Some(code) = output.status.code() { - anyhow!("neofetch process exited with status code: {code}") - } else { - #[cfg(unix)] - { - anyhow!( - "neofetch process terminated by signal: {}", - output - .status - .signal() - .expect("either one of status code or signal should be set") - ) - } - #[cfg(not(unix))] - unimplemented!("status code not expected to be `None` on non-Unix platforms") - }; - Err(err)?; - } + process_command_status(&output.status).context("neofetch command exited with error")?; let out = String::from_utf8(output.stdout) .context("failed to process neofetch output as it contains invalid UTF-8")? @@ -210,8 +362,67 @@ where } } +fn process_command_status(status: &ExitStatus) -> Result<()> { + if status.success() { + return Ok(()); + } + + let err = if let Some(code) = status.code() { + anyhow!("child process exited with status code: {code}") + } else { + #[cfg(unix)] + { + anyhow!( + "child process terminated by signal: {}", + status + .signal() + .expect("either one of status code or signal should be set") + ) + } + #[cfg(not(unix))] + unimplemented!("status code not expected to be `None` on non-Unix platforms") + }; + Err(err) +} + #[tracing::instrument(level = "debug")] fn get_distro_name() -> Result { run_neofetch_command_piped(&["ascii_distro_name"]) .context("failed to get distro name from neofetch") } + +/// Runs neofetch with colors. +#[tracing::instrument(level = "debug")] +fn run_neofetch(asc: String, args: Option<&Vec>) -> Result<()> { + // Escape backslashes here because backslashes are escaped in neofetch for + // printf + let asc = asc.replace('\\', r"\\"); + + // Write temp file + let mut temp_file = + NamedTempFile::with_prefix("ascii.txt").context("failed to create temp file for ascii")?; + temp_file + .write_all(asc.as_bytes()) + .context("failed to write ascii to temp file")?; + + // Call neofetch with the temp file + let temp_file_path = temp_file.into_temp_path(); + let args = { + let mut v = vec![ + "--ascii", + "--source", + temp_file_path + .to_str() + .expect("temp file path should not contain invalid UTF-8"), + "--ascii-colors", + ]; + if let Some(args) = args { + let args: Vec<_> = args.iter().map(|s| &**s).collect(); + v.extend(args); + } + v + }; + run_neofetch_command(&args).context("failed to run neofetch command")?; + + Ok(()) +} diff --git a/crates/hyfetch/src/presets.rs b/crates/hyfetch/src/presets.rs index 190b79bb..9770e076 100644 --- a/crates/hyfetch/src/presets.rs +++ b/crates/hyfetch/src/presets.rs @@ -146,8 +146,9 @@ impl Preset { // sourced from https://www.flagcolorcodes.com/autoromantic Self::Autoromantic => ColorProfile::from_hex_colors( // symbol interpreted - vec!["#99D9EA", "#99D9EA", "#3DA542", "#7F7F7F", "#7F7F7F"], - ), + vec!["#99D9EA", "#3DA542", "#7F7F7F"], + ) + .and_then(|c| c.with_weights(vec![2, 1, 2])), // sourced from https://www.flagcolorcodes.com/autosexual Self::Autosexual => ColorProfile::from_hex_colors(vec!["#99D9EA", "#7F7F7F"]), @@ -192,15 +193,17 @@ impl Preset { // used colorpicker to source form https://www.deviantart.com/pride-flags/art/Demifae-870194777 Self::Demifae => ColorProfile::from_hex_colors(vec![ - "#7F7F7F", "#7F7F7F", "#C5C5C5", "#C5C5C5", "#97C3A4", "#C4DEAE", "#FFFFFF", - "#FCA2C5", "#AB7EDF", "#C5C5C5", "#C5C5C5", "#7F7F7F", "#7F7F7F", - ]), + "#7F7F7F", "#C5C5C5", "#97C3A4", "#C4DEAE", "#FFFFFF", "#FCA2C5", "#AB7EDF", + "#C5C5C5", "#7F7F7F", + ]) + .and_then(|c| c.with_weights(vec![2, 2, 1, 1, 1, 1, 1, 2, 2])), // sourced from https://www.flagcolorcodes.com/demifaun Self::Demifaun => ColorProfile::from_hex_colors(vec![ - "#7F7F7F", "#7F7F7F", "#C6C6C6", "#C6C6C6", "#FCC688", "#FFF19C", "#FFFFFF", - "#8DE0D5", "#9682EC", "#C6C6C6", "#C6C6C6", "#7F7F7F", "#7F7F7F", - ]), + "#7F7F7F", "#C6C6C6", "#FCC688", "#FFF19C", "#FFFFFF", "#8DE0D5", "#9682EC", + "#C6C6C6", "#7F7F7F", + ]) + .and_then(|c| c.with_weights(vec![2, 2, 1, 1, 1, 1, 1, 2, 2])), // yellow sourced from https://lgbtqia.fandom.com/f/p/4400000000000041031 // other colors sourced from demiboy and demigirl flags @@ -272,9 +275,9 @@ impl Preset { // sourced from https://www.flagcolorcodes.com/greygender Self::Greygender => ColorProfile::from_hex_colors(vec![ - "#B3B3B3", "#B3B3B3", "#FFFFFF", "#062383", "#062383", "#FFFFFF", "#535353", - "#535353", - ]), + "#B3B3B3", "#FFFFFF", "#062383", "#FFFFFF", "#535353", + ]) + .and_then(|c| c.with_weights(vec![2, 1, 2, 1, 2])), // sourced from https://www.flagcolorcodes.com/greysexual Self::Greysexual => ColorProfile::from_hex_colors(vec![ @@ -381,7 +384,7 @@ impl Preset { "#FF6692", "#FF9A98", "#FFB883", "#FBFFA8", "#85BCFF", "#9D85FF", "#A510FF", ]), }) - .expect("presets should not be invalid") + .expect("preset color profiles should not be invalid") } } @@ -424,6 +427,46 @@ impl ColorProfile { Ok(Self::new(weighted_colors)) } + /// Creates a new color profile, with the colors spread to the specified + /// length. + pub fn with_length(&self, length: u8) -> Result { + let orig_len = self.colors.len(); + let orig_len: u8 = orig_len.try_into().expect("`orig_len` should fit in `u8`"); + if length < orig_len { + unimplemented!("compressing length of color profile not implemented"); + } + let center_i = (orig_len as f32 / 2.0).floor() as usize; + + // 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(); + + // How many extra spaces left? + let mut extras = length % orig_len; + + // If there is an odd space left, extend the center by one space + if extras % 2 == 1 { + extras -= 1; + weights[center_i] += 1; + } + + // Add weight to border until there's no space left (extras must be even at this + // point) + // TODO: this gives a horrible result when `extras` is still large relative to + // `orig_len` - we should probably distribute even if slightly uneven + let mut border_i = 0; + while extras > 0 { + extras -= 2; + weights[border_i] += 1; + let weights_len = weights.len(); + weights[weights_len - border_i - 1] += 1; + border_i += 1; + } + + self.with_weights(weights) + } + /// 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 =