Skip to main content

uv_console/
lib.rs

1use console::{Key, Term, measure_text_width, style};
2use std::{cmp::Ordering, 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 SI-prefixed size (binary units).
301///
302/// Returns a tuple of `(quantity, units)`.
303#[allow(
304    clippy::cast_possible_truncation,
305    clippy::cast_possible_wrap,
306    clippy::cast_precision_loss,
307    clippy::cast_sign_loss
308)]
309pub fn human_readable_bytes(bytes: u64) -> (f32, &'static str) {
310    const UNITS: [&str; 7] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"];
311    let bytes_f32 = bytes as f32;
312    let i = ((bytes_f32.log2() / 10.0) as usize).min(UNITS.len() - 1);
313    (bytes_f32 / 1024_f32.powi(i as i32), UNITS[i])
314}