1use std::{
6 io::{self, Write},
7 sync::OnceLock,
8 thread,
9 time::Duration,
10};
11
12use owo_colors::OwoColorize;
13
14static COLOR_ENABLED: OnceLock<bool> = OnceLock::new();
16
17static PIPE_MODE: OnceLock<bool> = OnceLock::new();
19
20pub fn colors_enabled() -> bool {
22 *COLOR_ENABLED.get_or_init(|| {
23 if std::env::var("NO_COLOR").is_ok() {
25 return false;
26 }
27 supports_color::on(supports_color::Stream::Stdout).is_some_and(|level| level.has_basic)
29 })
30}
31
32pub fn pipe_mode() -> bool {
37 *PIPE_MODE.get_or_init(|| {
38 use std::io::IsTerminal;
39 !std::io::stdout().is_terminal()
40 })
41}
42
43pub fn success(s: &str) -> String {
47 if colors_enabled() {
48 s.green().bold().to_string()
49 } else {
50 s.to_string()
51 }
52}
53
54pub fn warning(s: &str) -> String {
56 if colors_enabled() {
57 s.yellow().to_string()
58 } else {
59 s.to_string()
60 }
61}
62
63pub fn error(s: &str) -> String {
65 if colors_enabled() {
66 s.red().bold().to_string()
67 } else {
68 s.to_string()
69 }
70}
71
72pub fn info(s: &str) -> String {
74 if colors_enabled() {
75 s.cyan().to_string()
76 } else {
77 s.to_string()
78 }
79}
80
81pub fn warn(msg: &str) {
87 if !pipe_mode() {
88 print!("\r\x1b[K");
90 io::stdout().flush().ok();
91 }
92 eprintln!("{} {}", warning(icons::WARNING), warning(msg));
93}
94
95pub fn dim(s: &str) -> String {
97 if colors_enabled() {
98 s.dimmed().to_string()
99 } else {
100 s.to_string()
101 }
102}
103
104pub fn bold(s: &str) -> String {
106 if colors_enabled() {
107 s.bold().to_string()
108 } else {
109 s.to_string()
110 }
111}
112
113pub fn model(s: &str) -> String {
115 if colors_enabled() {
116 s.magenta().to_string()
117 } else {
118 s.to_string()
119 }
120}
121
122pub fn commit_type(s: &str) -> String {
124 if colors_enabled() {
125 s.blue().bold().to_string()
126 } else {
127 s.to_string()
128 }
129}
130
131pub fn scope(s: &str) -> String {
133 if colors_enabled() {
134 s.cyan().to_string()
135 } else {
136 s.to_string()
137 }
138}
139
140pub fn term_width() -> usize {
142 terminal_size::terminal_size()
143 .map_or(80, |(w, _)| w.0 as usize)
144 .min(120)
145}
146
147pub mod box_chars {
151 pub const TOP_LEFT: char = '\u{256D}';
152 pub const TOP_RIGHT: char = '\u{256E}';
153 pub const BOTTOM_LEFT: char = '\u{2570}';
154 pub const BOTTOM_RIGHT: char = '\u{256F}';
155 pub const HORIZONTAL: char = '\u{2500}';
156 pub const VERTICAL: char = '\u{2502}';
157}
158
159fn wrap_line(line: &str, max_width: usize) -> Vec<String> {
161 if line.is_empty() {
162 return vec![String::new()];
163 }
164
165 let mut lines = Vec::new();
166 let mut current = String::new();
167
168 for word in line.split_whitespace() {
169 let word_len = word.chars().count();
170 let current_len = current.chars().count();
171
172 if current.is_empty() {
173 current = word.to_string();
175 } else if current_len + 1 + word_len <= max_width {
176 current.push(' ');
178 current.push_str(word);
179 } else {
180 lines.push(current);
182 current = word.to_string();
183 }
184 }
185
186 if !current.is_empty() {
187 lines.push(current);
188 }
189
190 lines
191}
192
193pub fn boxed_message(title: &str, content: &str, width: usize) -> String {
195 use box_chars::*;
196
197 let mut out = String::new();
198 let inner_width = width.saturating_sub(4); let title_len = title.chars().count();
202 let border_width = width.saturating_sub(2);
203 let padding = border_width.saturating_sub(title_len + 2);
204 let left_pad = padding / 2;
205 let right_pad = padding - left_pad;
206
207 out.push(TOP_LEFT);
208 out.push_str(&HORIZONTAL.to_string().repeat(left_pad));
209 out.push(' ');
210 out.push_str(&if colors_enabled() {
211 bold(title)
212 } else {
213 title.to_string()
214 });
215 out.push(' ');
216 out.push_str(&HORIZONTAL.to_string().repeat(right_pad));
217 out.push(TOP_RIGHT);
218 out.push('\n');
219
220 for line in content.lines() {
222 let wrapped = wrap_line(line, inner_width);
223 for wrapped_line in wrapped {
224 out.push(VERTICAL);
225 out.push(' ');
226 let line_chars = wrapped_line.chars().count();
227 out.push_str(&wrapped_line);
228 let pad = inner_width.saturating_sub(line_chars);
229 out.push_str(&" ".repeat(pad));
230 out.push(' ');
231 out.push(VERTICAL);
232 out.push('\n');
233 }
234 }
235
236 out.push(BOTTOM_LEFT);
238 out.push_str(&HORIZONTAL.to_string().repeat(border_width));
239 out.push(BOTTOM_RIGHT);
240
241 out
242}
243
244pub fn print_info(msg: &str) {
246 use std::io::IsTerminal;
247 if std::io::stderr().is_terminal() && colors_enabled() {
248 eprintln!("\r\x1b[K{} {msg}", icons::INFO.cyan());
250 } else {
251 eprintln!("{} {msg}", icons::INFO);
252 }
253}
254
255pub fn separator(width: usize) -> String {
257 let line = box_chars::HORIZONTAL.to_string().repeat(width);
258 if colors_enabled() { dim(&line) } else { line }
259}
260
261pub fn section_header(title: &str, width: usize) -> String {
263 let title_len = title.chars().count();
264 let line_len = (width.saturating_sub(title_len + 2)) / 2;
265 let line = box_chars::HORIZONTAL.to_string().repeat(line_len);
266
267 if colors_enabled() {
268 format!("{} {} {}", dim(&line), bold(title), dim(&line))
269 } else {
270 format!("{line} {title} {line}")
271 }
272}
273
274pub mod icons {
277 pub const SUCCESS: &str = "\u{2713}";
278 pub const WARNING: &str = "\u{26A0}";
279 pub const ERROR: &str = "\u{2717}";
280 pub const INFO: &str = "\u{2139}";
281 pub const ARROW: &str = "\u{2192}";
282 pub const BULLET: &str = "\u{2022}";
283 pub const CLIPBOARD: &str = "\u{1F4CB}";
284 pub const SEARCH: &str = "\u{1F50D}";
285 pub const ROBOT: &str = "\u{1F916}";
286 pub const SAVE: &str = "\u{1F4BE}";
287}
288
289const SPINNER_FRAMES: &[char] = &[
292 '\u{280B}', '\u{2819}', '\u{2839}', '\u{2838}', '\u{283C}', '\u{2834}', '\u{2826}', '\u{2827}',
293 '\u{2807}', '\u{280F}',
294];
295
296pub async fn with_spinner<F: Future<Output = T>, T>(message: &str, f: F) -> T {
299 if !colors_enabled() {
301 eprintln!("{message}");
302 return f.await;
303 }
304 let (tx, rx) = std::sync::mpsc::channel::<()>();
305 let msg = message.to_string();
306
307 let spinner = thread::spawn(move || {
308 let mut idx = 0;
309 loop {
310 if rx.try_recv().is_ok() {
311 print!("\r\x1b[K{} {}\n", icons::SUCCESS.green(), msg);
313 io::stdout().flush().ok();
314 break;
315 }
316 print!("\r{} {}", SPINNER_FRAMES[idx].cyan(), msg);
317 io::stdout().flush().ok();
318 idx = (idx + 1) % SPINNER_FRAMES.len();
319 thread::sleep(Duration::from_millis(80));
320 }
321 });
322
323 let result = f.await;
324 tx.send(()).ok();
325 spinner.join().ok();
326 result
327}
328
329pub async fn with_spinner_result<F: Future<Output = Result<T, E>>, T, E>(
331 message: &str,
332 f: F,
333) -> Result<T, E> {
334 if !colors_enabled() {
335 eprintln!("{message}");
336 return f.await;
337 }
338 let (tx, rx) = std::sync::mpsc::channel::<bool>();
339 let msg = message.to_string();
340
341 let spinner = thread::spawn(move || {
342 let mut idx = 0;
343 loop {
344 match rx.try_recv() {
345 Ok(success) => {
346 let icon = if success {
347 icons::SUCCESS.green().to_string()
348 } else {
349 icons::ERROR.red().to_string()
350 };
351 print!("\r\x1b[K{icon} {msg}\n");
352 io::stdout().flush().ok();
353 break;
354 },
355 Err(std::sync::mpsc::TryRecvError::Disconnected) => break,
356 Err(std::sync::mpsc::TryRecvError::Empty) => {},
357 }
358 print!("\r{} {}", SPINNER_FRAMES[idx].cyan(), msg);
359 io::stdout().flush().ok();
360 idx = (idx + 1) % SPINNER_FRAMES.len();
361 thread::sleep(Duration::from_millis(80));
362 }
363 });
364
365 let result = f.await;
366 tx.send(result.is_ok()).ok();
367 spinner.join().ok();
368 result
369}