Skip to main content

uv_console/
lib.rs

1use console::{Key, Term, measure_text_width, style};
2use std::{cmp::Ordering, fmt, iter};
3
4/// Prompt the user for confirmation in the given [`Term`].
5///
6/// This is a slimmed-down version of `dialoguer::Confirm`, with the post-confirmation report
7/// enabled.
8pub fn confirm(message: &str, term: &Term, default: bool) -> std::io::Result<bool> {
9    confirm_inner(message, None, term, default)
10}
11
12/// Prompt the user for confirmation in the given [`Term`], with a hint.
13pub fn confirm_with_hint(
14    message: &str,
15    hint: &str,
16    term: &Term,
17    default: bool,
18) -> std::io::Result<bool> {
19    confirm_inner(message, Some(hint), term, default)
20}
21
22fn confirm_inner(
23    message: &str,
24    hint: Option<&str>,
25    term: &Term,
26    default: bool,
27) -> std::io::Result<bool> {
28    let prompt = format!(
29        "{} {} {} {} {}",
30        style("?".to_string()).for_stderr().yellow(),
31        style(message).for_stderr().bold(),
32        style("[y/n]").for_stderr().black().bright(),
33        style("›").for_stderr().black().bright(),
34        style(if default { "yes" } else { "no" })
35            .for_stderr()
36            .cyan(),
37    );
38
39    term.write_str(&prompt)?;
40    if let Some(hint) = hint {
41        term.write_str(&format!(
42            "\n\n{}{} {hint}",
43            style("hint").for_stderr().bold().cyan(),
44            style(":").for_stderr().bold()
45        ))?;
46    }
47    term.hide_cursor()?;
48    term.flush()?;
49
50    // Match continuously on every keystroke, and do not wait for user to hit the
51    // `Enter` key.
52    let response = loop {
53        let input = term.read_key_raw()?;
54        match input {
55            Key::Char('y' | 'Y') => break true,
56            Key::Char('n' | 'N') => break false,
57            Key::Enter => break default,
58            Key::CtrlC => {
59                let term = Term::stderr();
60                term.show_cursor()?;
61                term.write_str("\n")?;
62                term.flush()?;
63
64                #[expect(clippy::exit, clippy::cast_possible_wrap)]
65                std::process::exit(if cfg!(windows) {
66                    0xC000_013A_u32 as i32
67                } else {
68                    130
69                });
70            }
71            _ => {}
72        }
73    };
74
75    let report = format!(
76        "{} {} {} {}",
77        style("✔".to_string()).for_stderr().green(),
78        style(message).for_stderr().bold(),
79        style("·").for_stderr().black().bright(),
80        style(if response { "yes" } else { "no" })
81            .for_stderr()
82            .cyan(),
83    );
84
85    if hint.is_some() {
86        term.clear_last_lines(2)?;
87        // It's not clear why we need to clear to the end of the screen here, but it fixes lingering
88        // display of the hint on `bash` (the issue did not reproduce on `zsh`).
89        term.clear_to_end_of_screen()?;
90    } else {
91        term.clear_line()?;
92    }
93    term.write_line(&report)?;
94    term.show_cursor()?;
95    term.flush()?;
96
97    Ok(response)
98}
99
100/// Prompt the user for password in the given [`Term`].
101///
102/// This is a slimmed-down version of `dialoguer::Password`.
103pub fn password(prompt: &str, term: &Term) -> std::io::Result<String> {
104    term.write_str(prompt)?;
105    term.show_cursor()?;
106    term.flush()?;
107
108    let input = term.read_secure_line()?;
109
110    term.clear_line()?;
111
112    Ok(input)
113}
114
115/// Prompt the user for username in the given [`Term`].
116pub fn username(prompt: &str, term: &Term) -> std::io::Result<String> {
117    term.write_str(prompt)?;
118    term.show_cursor()?;
119    term.flush()?;
120
121    let input = term.read_line()?;
122
123    term.clear_line()?;
124
125    Ok(input)
126}
127
128/// Prompt the user for input text in the given [`Term`].
129///
130/// This is a slimmed-down version of `dialoguer::Input`.
131#[allow(
132    // Suppress Clippy lints triggered by `dialoguer::Input`.
133    clippy::cast_possible_truncation,
134    clippy::cast_possible_wrap,
135    clippy::cast_sign_loss
136)]
137pub fn input(prompt: &str, term: &Term) -> std::io::Result<String> {
138    term.write_str(prompt)?;
139    term.show_cursor()?;
140    term.flush()?;
141
142    let prompt_len = measure_text_width(prompt);
143
144    let mut chars: Vec<char> = Vec::new();
145    let mut position = 0;
146    loop {
147        match term.read_key()? {
148            Key::Backspace if position > 0 => {
149                position -= 1;
150                chars.remove(position);
151                let line_size = term.size().1 as usize;
152                // Case we want to delete last char of a line so the cursor is at the beginning of the next line
153                if (position + prompt_len).is_multiple_of(line_size - 1) {
154                    term.clear_line()?;
155                    term.move_cursor_up(1)?;
156                    term.move_cursor_right(line_size + 1)?;
157                } else {
158                    term.clear_chars(1)?;
159                }
160
161                let tail: String = chars[position..].iter().collect();
162
163                if !tail.is_empty() {
164                    term.write_str(&tail)?;
165
166                    let total = position + prompt_len + tail.chars().count();
167                    let total_line = total / line_size;
168                    let line_cursor = (position + prompt_len) / line_size;
169                    term.move_cursor_up(total_line - line_cursor)?;
170
171                    term.move_cursor_left(line_size)?;
172                    term.move_cursor_right((position + prompt_len) % line_size)?;
173                }
174
175                term.flush()?;
176            }
177            Key::Char(chr) if !chr.is_ascii_control() => {
178                chars.insert(position, chr);
179                position += 1;
180                let tail: String = iter::once(&chr).chain(chars[position..].iter()).collect();
181                term.write_str(&tail)?;
182                term.move_cursor_left(tail.chars().count() - 1)?;
183                term.flush()?;
184            }
185            Key::ArrowLeft if position > 0 => {
186                if (position + prompt_len).is_multiple_of(term.size().1 as usize) {
187                    term.move_cursor_up(1)?;
188                    term.move_cursor_right(term.size().1 as usize)?;
189                } else {
190                    term.move_cursor_left(1)?;
191                }
192                position -= 1;
193                term.flush()?;
194            }
195            Key::ArrowRight if position < chars.len() => {
196                if (position + prompt_len).is_multiple_of(term.size().1 as usize - 1) {
197                    term.move_cursor_down(1)?;
198                    term.move_cursor_left(term.size().1 as usize)?;
199                } else {
200                    term.move_cursor_right(1)?;
201                }
202                position += 1;
203                term.flush()?;
204            }
205            Key::UnknownEscSeq(seq) if seq == vec!['b'] => {
206                let line_size = term.size().1 as usize;
207                let nb_space = chars[..position]
208                    .iter()
209                    .rev()
210                    .take_while(|c| c.is_whitespace())
211                    .count();
212                let find_last_space = chars[..position - nb_space]
213                    .iter()
214                    .rposition(|c| c.is_whitespace());
215
216                // If we find a space we set the cursor to the next char else we set it to the beginning of the input
217                if let Some(mut last_space) = find_last_space {
218                    if last_space < position {
219                        last_space += 1;
220                        let new_line = (prompt_len + last_space) / line_size;
221                        let old_line = (prompt_len + position) / line_size;
222                        let diff_line = old_line - new_line;
223                        if diff_line != 0 {
224                            term.move_cursor_up(old_line - new_line)?;
225                        }
226
227                        let new_pos_x = (prompt_len + last_space) % line_size;
228                        let old_pos_x = (prompt_len + position) % line_size;
229                        let diff_pos_x = new_pos_x as i64 - old_pos_x as i64;
230                        if diff_pos_x < 0 {
231                            term.move_cursor_left(-diff_pos_x as usize)?;
232                        } else {
233                            term.move_cursor_right((diff_pos_x) as usize)?;
234                        }
235                        position = last_space;
236                    }
237                } else {
238                    term.move_cursor_left(position)?;
239                    position = 0;
240                }
241
242                term.flush()?;
243            }
244            Key::UnknownEscSeq(seq) if seq == vec!['f'] => {
245                let line_size = term.size().1 as usize;
246                let find_next_space = chars[position..].iter().position(|c| c.is_whitespace());
247
248                // If we find a space we set the cursor to the next char else we set it to the beginning of the input
249                if let Some(mut next_space) = find_next_space {
250                    let nb_space = chars[position + next_space..]
251                        .iter()
252                        .take_while(|c| c.is_whitespace())
253                        .count();
254                    next_space += nb_space;
255                    let new_line = (prompt_len + position + next_space) / line_size;
256                    let old_line = (prompt_len + position) / line_size;
257                    term.move_cursor_down(new_line - old_line)?;
258
259                    let new_pos_x = (prompt_len + position + next_space) % line_size;
260                    let old_pos_x = (prompt_len + position) % line_size;
261                    let diff_pos_x = new_pos_x as i64 - old_pos_x as i64;
262                    if diff_pos_x < 0 {
263                        term.move_cursor_left(-diff_pos_x as usize)?;
264                    } else {
265                        term.move_cursor_right((diff_pos_x) as usize)?;
266                    }
267                    position += next_space;
268                } else {
269                    let new_line = (prompt_len + chars.len()) / line_size;
270                    let old_line = (prompt_len + position) / line_size;
271                    term.move_cursor_down(new_line - old_line)?;
272
273                    let new_pos_x = (prompt_len + chars.len()) % line_size;
274                    let old_pos_x = (prompt_len + position) % line_size;
275                    let diff_pos_x = new_pos_x as i64 - old_pos_x as i64;
276                    match diff_pos_x.cmp(&0) {
277                        Ordering::Less => {
278                            term.move_cursor_left((-diff_pos_x - 1) as usize)?;
279                        }
280                        Ordering::Equal => {}
281                        Ordering::Greater => {
282                            term.move_cursor_right((diff_pos_x) as usize)?;
283                        }
284                    }
285                    position = chars.len();
286                }
287
288                term.flush()?;
289            }
290            Key::Enter => break,
291            _ => (),
292        }
293    }
294    let input = chars.iter().collect::<String>();
295    term.write_line("")?;
296
297    Ok(input)
298}
299
300/// Formats a number of bytes into a human readable IEC-prefixed size (binary units).
301#[expect(clippy::cast_precision_loss)]
302pub fn human_readable_bytes(bytes: u64) -> impl fmt::Display {
303    const UNITS: [&str; 7] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"];
304
305    fmt::from_fn(move |formatter| {
306        if bytes < 1024 {
307            return write!(formatter, "{bytes}B");
308        }
309
310        let rounding_margin = formatter
311            .precision()
312            .and_then(|precision| i32::try_from(precision).ok())
313            .map_or(0.0, |precision| 0.5 / 10_f64.powi(precision));
314        let mut quantity = bytes as f64;
315        let [mut unit, units @ ..] = UNITS;
316
317        for next_unit in units {
318            if 1024.0 - quantity > rounding_margin {
319                break;
320            }
321            quantity /= 1024.0;
322            unit = next_unit;
323        }
324
325        fmt::Display::fmt(&quantity, formatter)?;
326        formatter.write_str(unit)
327    })
328}
329
330#[cfg(test)]
331mod tests {
332    use super::human_readable_bytes;
333
334    #[test]
335    fn human_readable_sizes() {
336        const MIB: u64 = 1024_u64.pow(2);
337        const PIB: u64 = 1024_u64.pow(5);
338
339        assert_eq!(format!("{}", human_readable_bytes(0)), "0B");
340        assert_eq!(format!("{}", human_readable_bytes(1024)), "1KiB");
341        assert_eq!(format!("{}", human_readable_bytes(1536)), "1.5KiB");
342        assert_eq!(format!("{}", human_readable_bytes(u64::MAX)), "16EiB");
343        assert_eq!(
344            format!("{}", human_readable_bytes(MIB - 51)),
345            "1023.9501953125KiB"
346        );
347
348        assert_eq!(format!("{:.2}", human_readable_bytes(1023)), "1023B");
349        assert_eq!(format!("{:.0}", human_readable_bytes(MIB - 513)), "1023KiB");
350        assert_eq!(format!("{:.0}", human_readable_bytes(MIB - 512)), "1MiB");
351        assert_eq!(
352            format!("{:.1}", human_readable_bytes(MIB - 52)),
353            "1023.9KiB"
354        );
355        assert_eq!(format!("{:.1}", human_readable_bytes(MIB - 51)), "1.0MiB");
356        assert_eq!(
357            format!("{:.2}", human_readable_bytes(MIB - 51)),
358            "1023.95KiB"
359        );
360
361        assert_eq!(
362            format!("{:.10}", human_readable_bytes(PIB - 55)),
363            "1023.9999999999TiB"
364        );
365        // Specifically trigger a floating point imprecision corner case.
366        assert_eq!(
367            format!("{:.1}", human_readable_bytes(PIB * 1024 - PIB / 20 - 1)),
368            "1.0EiB"
369        );
370    }
371}