Skip to main content

langcodec_cli/
ui.rs

1use atty::Stream;
2use clap::builder::styling::{AnsiColor, Effects, Styles};
3use crossterm::style::{Attribute, Color, Stylize, style};
4use std::fmt::Display;
5use unicode_width::UnicodeWidthStr;
6
7#[derive(Clone, Copy)]
8pub enum Tone {
9    Success,
10    Error,
11    Warning,
12    Info,
13    Accent,
14    Muted,
15}
16
17pub fn clap_styles() -> Styles {
18    Styles::styled()
19        .header(AnsiColor::Cyan.on_default().effects(Effects::BOLD))
20        .usage(AnsiColor::Cyan.on_default().effects(Effects::BOLD))
21        .literal(AnsiColor::Yellow.on_default().effects(Effects::BOLD))
22        .placeholder(AnsiColor::Green.on_default())
23        .valid(AnsiColor::Green.on_default().effects(Effects::BOLD))
24        .invalid(AnsiColor::Red.on_default().effects(Effects::BOLD))
25        .error(AnsiColor::Red.on_default().effects(Effects::BOLD))
26}
27
28fn colors_enabled(stream: Stream) -> bool {
29    std::env::var_os("NO_COLOR").is_none() && atty::is(stream)
30}
31
32pub fn stdout_styled() -> bool {
33    colors_enabled(Stream::Stdout)
34}
35
36pub fn stderr_styled() -> bool {
37    colors_enabled(Stream::Stderr)
38}
39
40fn tone_color(tone: Tone) -> Color {
41    match tone {
42        Tone::Success => Color::Green,
43        Tone::Error => Color::Red,
44        Tone::Warning => Color::Yellow,
45        Tone::Info => Color::Blue,
46        Tone::Accent => Color::Cyan,
47        Tone::Muted => Color::DarkGrey,
48    }
49}
50
51fn tone_label(tone: Tone) -> &'static str {
52    match tone {
53        Tone::Success => "OK",
54        Tone::Error => "ERR",
55        Tone::Warning => "WARN",
56        Tone::Info => "INFO",
57        Tone::Accent => "NOTE",
58        Tone::Muted => "··",
59    }
60}
61
62fn plain_prefix(tone: Tone) -> &'static str {
63    match tone {
64        Tone::Success => "✅",
65        Tone::Error => "❌",
66        Tone::Warning => "⚠️",
67        Tone::Info => "ℹ️",
68        Tone::Accent => "•",
69        Tone::Muted => "·",
70    }
71}
72
73pub fn tone_text(text: &str, tone: Tone) -> String {
74    if stdout_styled() {
75        format!(
76            "{}",
77            style(text)
78                .with(tone_color(tone))
79                .attribute(Attribute::Bold)
80        )
81    } else {
82        text.to_string()
83    }
84}
85
86pub fn accent(text: &str) -> String {
87    if stdout_styled() {
88        format!(
89            "{}",
90            style(text).with(Color::Cyan).attribute(Attribute::Bold)
91        )
92    } else {
93        text.to_string()
94    }
95}
96
97pub fn muted(text: &str) -> String {
98    if stdout_styled() {
99        format!("{}", style(text).with(Color::DarkGrey))
100    } else {
101        text.to_string()
102    }
103}
104
105pub fn divider(width: usize) -> String {
106    if stdout_styled() {
107        format!("{}", style("─".repeat(width)).with(Color::DarkGrey))
108    } else {
109        "-".repeat(width)
110    }
111}
112
113pub fn header(title: &str) -> String {
114    if stdout_styled() {
115        format!(
116            "{}\n{}",
117            accent(title),
118            divider(title.chars().count().max(24))
119        )
120    } else {
121        format!("=== {} ===", title)
122    }
123}
124
125pub fn section(title: &str) -> String {
126    if stdout_styled() {
127        format!("\n{}", accent(title))
128    } else {
129        format!("\n=== {} ===", title)
130    }
131}
132
133pub fn key_value(label: &str, value: impl Display) -> String {
134    if stdout_styled() {
135        let width = UnicodeWidthStr::width(label).min(18);
136        let padding = 18usize.saturating_sub(width).max(1);
137        format!("{}{}{}", muted(label), " ".repeat(padding), value)
138    } else {
139        format!("{label}: {value}")
140    }
141}
142
143pub fn status_line_stdout(tone: Tone, message: &str) -> String {
144    if stdout_styled() {
145        format!(
146            "{} {}",
147            style(format!(" {} ", tone_label(tone)))
148                .with(tone_color(tone))
149                .attribute(Attribute::Bold),
150            message
151        )
152    } else {
153        format!("{} {}", plain_prefix(tone), message)
154    }
155}
156
157pub fn status_line_stderr(tone: Tone, message: &str) -> String {
158    if stderr_styled() {
159        format!(
160            "{} {}",
161            style(format!(" {} ", tone_label(tone)))
162                .with(tone_color(tone))
163                .attribute(Attribute::Bold),
164            message
165        )
166    } else {
167        format!("{} {}", plain_prefix(tone), message)
168    }
169}
170
171pub fn progress_bar(ratio: f64, width: usize) -> String {
172    let clamped = ratio.clamp(0.0, 1.0);
173    if stdout_styled() {
174        let filled = (clamped * width as f64).round() as usize;
175        let empty = width.saturating_sub(filled);
176        let left = format!("{}", style("█".repeat(filled)).with(Color::Green));
177        let right = format!("{}", style("░".repeat(empty)).with(Color::DarkGrey));
178        format!("{left}{right}")
179    } else {
180        let percent = (clamped * 100.0).round() as usize;
181        format!("{percent}%")
182    }
183}