Skip to main content

capulus/
ui.rs

1use std::io::{self, IsTerminal};
2use std::sync::LazyLock;
3use std::time::Duration;
4
5use anyhow::{Result, bail};
6use dialoguer::Confirm;
7use dialoguer::theme::ColorfulTheme;
8use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
9use serde::{Deserialize, Serialize};
10
11pub use crate::progress::{
12    CancellationMode, ColorMode, LiveGroup, LiveRow, ProgressMode, Task, TaskKind, TaskOptions,
13    TaskVisibility, Ui, UiOptions, ValidatedUiOptions, format_duration,
14};
15pub use crate::{Cancellation, Cancelled};
16
17const ANSI_BLUE: &str = "\x1b[34m";
18const ANSI_BOLD_CYAN: &str = "\x1b[1;36m";
19const ANSI_BOLD_GREEN: &str = "\x1b[1;32m";
20const ANSI_BOLD_RED: &str = "\x1b[1;31m";
21const ANSI_BOLD_WHITE_RED_BG: &str = "\x1b[1;37;41m";
22const ANSI_BOLD_YELLOW: &str = "\x1b[1;33m";
23const ANSI_CYAN: &str = "\x1b[36m";
24const ANSI_GREEN: &str = "\x1b[32m";
25const ANSI_RED: &str = "\x1b[31m";
26const ANSI_RESET: &str = "\x1b[0m";
27const ANSI_STRIKETHROUGH: &str = "\x1b[9m";
28const ANSI_YELLOW: &str = "\x1b[33m";
29const SPINNER_TICKS: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32pub enum Color {
33    Blue,
34    Cyan,
35    Green,
36    Red,
37    Yellow,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
41pub enum TextEffect {
42    #[default]
43    None,
44    Strikethrough,
45}
46
47pub trait RenderTarget {
48    fn style(&self, text: &str, color: Option<Color>, effect: TextEffect) -> String;
49
50    fn paint(&self, text: &str, color: Color) -> String {
51        self.style(text, Some(color), TextEffect::None)
52    }
53
54    fn effect(&self, text: &str, effect: TextEffect) -> String {
55        self.style(text, None, effect)
56    }
57
58    fn hyperlink(&self, text: &str, url: Option<&str>) -> String {
59        let _ = url;
60        text.to_owned()
61    }
62}
63
64#[derive(Debug, Clone, Copy, Default)]
65pub struct StdoutRenderTarget;
66
67#[derive(Debug, Clone, Copy, Default)]
68pub struct StderrRenderTarget;
69
70#[derive(Debug, Clone, Copy)]
71pub struct ConfiguredRenderTarget {
72    color: bool,
73}
74
75pub fn stdin_is_interactive() -> bool {
76    io::stdin().is_terminal()
77}
78
79pub fn stdout_is_interactive() -> bool {
80    io::stdout().is_terminal()
81}
82
83pub fn stderr_is_interactive() -> bool {
84    io::stderr().is_terminal()
85}
86
87pub fn require_interactive(message: &str) -> Result<()> {
88    if stdin_is_interactive() {
89        Ok(())
90    } else {
91        bail!("{message}")
92    }
93}
94
95pub fn prompt_confirm(prompt: &str, default: bool) -> Result<bool> {
96    prompt_confirm_with_message(prompt, default, "Interactive confirmation required.")
97}
98
99pub fn prompt_confirm_with_message(prompt: &str, default: bool, message: &str) -> Result<bool> {
100    require_interactive(message)?;
101    Confirm::with_theme(prompt_theme())
102        .with_prompt(prompt)
103        .default(default)
104        .interact()
105        .map_err(Into::into)
106}
107
108pub fn prompt_theme() -> &'static ColorfulTheme {
109    static THEME: LazyLock<ColorfulTheme> = LazyLock::new(ColorfulTheme::default);
110    &THEME
111}
112
113pub fn stdout_render_target() -> StdoutRenderTarget {
114    StdoutRenderTarget
115}
116
117pub fn stderr_render_target() -> StderrRenderTarget {
118    StderrRenderTarget
119}
120
121impl Ui {
122    /// Returns a renderer that follows this invocation's resolved color mode.
123    pub fn render_target(&self) -> ConfiguredRenderTarget {
124        ConfiguredRenderTarget {
125            color: self.color_is_enabled(),
126        }
127    }
128
129    /// Returns a stdout renderer that follows the invocation color policy
130    /// without adding terminal escapes to redirected payload output in auto mode.
131    pub fn stdout_render_target(&self) -> ConfiguredRenderTarget {
132        ConfiguredRenderTarget {
133            color: self.stdout_color_is_enabled(),
134        }
135    }
136}
137
138pub fn spinner_style(template: &str) -> ProgressStyle {
139    ProgressStyle::with_template(template)
140        .unwrap_or_else(|_| ProgressStyle::default_spinner())
141        .tick_strings(SPINNER_TICKS)
142}
143
144pub fn plain_message_style() -> ProgressStyle {
145    ProgressStyle::with_template("{msg}").unwrap_or_else(|_| ProgressStyle::default_spinner())
146}
147
148pub fn spinner(message: &str) -> ProgressBar {
149    let progress = ProgressBar::with_draw_target(None, ProgressDrawTarget::stderr());
150    progress.set_style(spinner_style("{spinner:.cyan} {msg}"));
151    progress.enable_steady_tick(Duration::from_millis(90));
152    progress.set_message(message.to_owned());
153    progress
154}
155
156pub fn new_list_row_spinner() -> ProgressBar {
157    let row = ProgressBar::new_spinner();
158    row.set_style(spinner_style("{spinner:.cyan} {msg}"));
159    row
160}
161
162pub fn new_list_spacer() -> ProgressBar {
163    let spacer = ProgressBar::new_spinner();
164    spacer.set_style(plain_message_style());
165    spacer
166}
167
168pub fn activate_list_row_spinner(row: &ProgressBar) {
169    row.enable_steady_tick(Duration::from_millis(90));
170}
171
172pub fn finish_list_spacer(spacer: &ProgressBar) {
173    spacer.finish_with_message(" ".to_owned());
174}
175
176pub fn finish_list_row(row: &ProgressBar, message: &str) {
177    row.set_style(plain_message_style());
178    row.finish_with_message(message.to_owned());
179}
180
181pub fn clear_list_spacer(progress: &MultiProgress, spacer: &ProgressBar) {
182    progress.remove(spacer);
183}
184
185pub fn clear_progress_bar(progress: &MultiProgress, progress_bar: &ProgressBar) {
186    progress_bar.finish_and_clear();
187    progress.remove(progress_bar);
188}
189
190pub fn maybe_open_browser(url: &str) {
191    if let Err(err) = webbrowser::open(url) {
192        warn(&format!(
193            "Could not open a browser automatically: {err}. Open this URL manually: {url}"
194        ));
195    }
196}
197
198pub fn print_big_red_error(message: &str) {
199    if stderr_is_interactive() {
200        eprintln!(
201            "{} ERROR {} {}{}{}",
202            ANSI_BOLD_WHITE_RED_BG, ANSI_RESET, ANSI_BOLD_RED, message, ANSI_RESET
203        );
204    } else {
205        eprintln!("ERROR: {message}");
206    }
207}
208
209pub fn print_notice(message: &str) {
210    if stderr_is_interactive() {
211        let prefix = StderrRenderTarget.paint("INFO", Color::Cyan);
212        eprintln!("{prefix} {message}");
213    } else {
214        eprintln!("INFO: {message}");
215    }
216}
217
218pub fn print_warning(message: &str) {
219    warn(message);
220}
221
222pub fn print_error(message: &str) {
223    if stderr_is_interactive() {
224        eprintln!("{ANSI_BOLD_RED}error:{ANSI_RESET} {message}");
225    } else {
226        eprintln!("error: {message}");
227    }
228}
229
230pub fn print_stage(message: &str) {
231    stage(message);
232}
233
234pub fn stage(message: &str) {
235    if stderr_is_interactive() {
236        eprintln!("{ANSI_BOLD_CYAN}==>{ANSI_RESET} {message}");
237    } else {
238        eprintln!("==> {message}");
239    }
240}
241
242pub fn detail(message: &str) {
243    eprintln!("    {message}");
244}
245
246pub fn success(message: &str) {
247    if stderr_is_interactive() {
248        eprintln!("{ANSI_BOLD_GREEN}ok:{ANSI_RESET} {message}");
249    } else {
250        eprintln!("ok: {message}");
251    }
252}
253
254pub fn warn(message: &str) {
255    if stderr_is_interactive() {
256        eprintln!("{ANSI_BOLD_YELLOW}warning:{ANSI_RESET} {message}");
257    } else {
258        eprintln!("warning: {message}");
259    }
260}
261
262impl RenderTarget for StdoutRenderTarget {
263    fn style(&self, text: &str, color: Option<Color>, effect: TextEffect) -> String {
264        style_for_terminal(text, color, effect, stdout_is_interactive())
265    }
266
267    fn hyperlink(&self, text: &str, url: Option<&str>) -> String {
268        if !stdout_is_interactive() {
269            return text.to_owned();
270        }
271        let Some(url) = url else {
272            return text.to_owned();
273        };
274        format!("\x1b]8;;{url}\x1b\\{text}\x1b]8;;\x1b\\")
275    }
276}
277
278impl RenderTarget for StderrRenderTarget {
279    fn style(&self, text: &str, color: Option<Color>, effect: TextEffect) -> String {
280        style_for_terminal(text, color, effect, stderr_is_interactive())
281    }
282}
283
284impl RenderTarget for ConfiguredRenderTarget {
285    fn style(&self, text: &str, color: Option<Color>, effect: TextEffect) -> String {
286        style_for_terminal(text, color, effect, self.color)
287    }
288}
289
290fn ansi_code(color: Color) -> &'static str {
291    match color {
292        Color::Blue => ANSI_BLUE,
293        Color::Cyan => ANSI_CYAN,
294        Color::Green => ANSI_GREEN,
295        Color::Red => ANSI_RED,
296        Color::Yellow => ANSI_YELLOW,
297    }
298}
299
300fn style_for_terminal(
301    text: &str,
302    color: Option<Color>,
303    effect: TextEffect,
304    is_interactive: bool,
305) -> String {
306    if !is_interactive || (color.is_none() && effect == TextEffect::None) {
307        return text.to_owned();
308    }
309    let mut prefix = String::new();
310    if effect == TextEffect::Strikethrough {
311        prefix.push_str(ANSI_STRIKETHROUGH);
312    }
313    if let Some(color) = color {
314        prefix.push_str(ansi_code(color));
315    }
316    format!("{prefix}{text}{ANSI_RESET}")
317}