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
17pub fn colors_enabled() -> bool {
19 *COLOR_ENABLED.get_or_init(|| {
20 if std::env::var("NO_COLOR").is_ok() {
22 return false;
23 }
24 supports_color::on(supports_color::Stream::Stdout).is_some_and(|level| level.has_basic)
26 })
27}
28
29pub fn success(s: &str) -> String {
33 if colors_enabled() {
34 s.green().bold().to_string()
35 } else {
36 s.to_string()
37 }
38}
39
40pub fn warning(s: &str) -> String {
42 if colors_enabled() {
43 s.yellow().to_string()
44 } else {
45 s.to_string()
46 }
47}
48
49pub fn error(s: &str) -> String {
51 if colors_enabled() {
52 s.red().bold().to_string()
53 } else {
54 s.to_string()
55 }
56}
57
58pub fn info(s: &str) -> String {
60 if colors_enabled() {
61 s.cyan().to_string()
62 } else {
63 s.to_string()
64 }
65}
66
67pub fn warn(msg: &str) {
73 print!("\r\x1b[K");
75 io::stdout().flush().ok();
76 eprintln!("{} {}", warning(icons::WARNING), warning(msg));
77}
78
79pub fn dim(s: &str) -> String {
81 if colors_enabled() {
82 s.dimmed().to_string()
83 } else {
84 s.to_string()
85 }
86}
87
88pub fn bold(s: &str) -> String {
90 if colors_enabled() {
91 s.bold().to_string()
92 } else {
93 s.to_string()
94 }
95}
96
97pub fn model(s: &str) -> String {
99 if colors_enabled() {
100 s.magenta().to_string()
101 } else {
102 s.to_string()
103 }
104}
105
106pub fn commit_type(s: &str) -> String {
108 if colors_enabled() {
109 s.blue().bold().to_string()
110 } else {
111 s.to_string()
112 }
113}
114
115pub fn scope(s: &str) -> String {
117 if colors_enabled() {
118 s.cyan().to_string()
119 } else {
120 s.to_string()
121 }
122}
123
124pub fn term_width() -> usize {
126 terminal_size::terminal_size()
127 .map_or(80, |(w, _)| w.0 as usize)
128 .min(120)
129}
130
131pub mod box_chars {
135 pub const TOP_LEFT: char = '\u{256D}';
136 pub const TOP_RIGHT: char = '\u{256E}';
137 pub const BOTTOM_LEFT: char = '\u{2570}';
138 pub const BOTTOM_RIGHT: char = '\u{256F}';
139 pub const HORIZONTAL: char = '\u{2500}';
140 pub const VERTICAL: char = '\u{2502}';
141}
142
143fn wrap_line(line: &str, max_width: usize) -> Vec<String> {
145 if line.is_empty() {
146 return vec![String::new()];
147 }
148
149 let mut lines = Vec::new();
150 let mut current = String::new();
151
152 for word in line.split_whitespace() {
153 let word_len = word.chars().count();
154 let current_len = current.chars().count();
155
156 if current.is_empty() {
157 current = word.to_string();
159 } else if current_len + 1 + word_len <= max_width {
160 current.push(' ');
162 current.push_str(word);
163 } else {
164 lines.push(current);
166 current = word.to_string();
167 }
168 }
169
170 if !current.is_empty() {
171 lines.push(current);
172 }
173
174 lines
175}
176
177pub fn boxed_message(title: &str, content: &str, width: usize) -> String {
179 use box_chars::*;
180
181 let mut out = String::new();
182 let inner_width = width.saturating_sub(4); let title_len = title.chars().count();
186 let border_width = width.saturating_sub(2);
187 let padding = border_width.saturating_sub(title_len + 2);
188 let left_pad = padding / 2;
189 let right_pad = padding - left_pad;
190
191 out.push(TOP_LEFT);
192 out.push_str(&HORIZONTAL.to_string().repeat(left_pad));
193 out.push(' ');
194 out.push_str(&if colors_enabled() {
195 bold(title)
196 } else {
197 title.to_string()
198 });
199 out.push(' ');
200 out.push_str(&HORIZONTAL.to_string().repeat(right_pad));
201 out.push(TOP_RIGHT);
202 out.push('\n');
203
204 for line in content.lines() {
206 let wrapped = wrap_line(line, inner_width);
207 for wrapped_line in wrapped {
208 out.push(VERTICAL);
209 out.push(' ');
210 let line_chars = wrapped_line.chars().count();
211 out.push_str(&wrapped_line);
212 let pad = inner_width.saturating_sub(line_chars);
213 out.push_str(&" ".repeat(pad));
214 out.push(' ');
215 out.push(VERTICAL);
216 out.push('\n');
217 }
218 }
219
220 out.push(BOTTOM_LEFT);
222 out.push_str(&HORIZONTAL.to_string().repeat(border_width));
223 out.push(BOTTOM_RIGHT);
224
225 out
226}
227
228pub fn print_info(msg: &str) {
230 use std::io::IsTerminal;
231 if std::io::stderr().is_terminal() && colors_enabled() {
232 eprintln!("\r\x1b[K{} {msg}", icons::INFO.cyan());
234 } else {
235 eprintln!("{} {msg}", icons::INFO);
236 }
237}
238
239pub fn separator(width: usize) -> String {
241 let line = box_chars::HORIZONTAL.to_string().repeat(width);
242 if colors_enabled() { dim(&line) } else { line }
243}
244
245pub fn section_header(title: &str, width: usize) -> String {
247 let title_len = title.chars().count();
248 let line_len = (width.saturating_sub(title_len + 2)) / 2;
249 let line = box_chars::HORIZONTAL.to_string().repeat(line_len);
250
251 if colors_enabled() {
252 format!("{} {} {}", dim(&line), bold(title), dim(&line))
253 } else {
254 format!("{line} {title} {line}")
255 }
256}
257
258pub mod icons {
261 pub const SUCCESS: &str = "\u{2713}";
262 pub const WARNING: &str = "\u{26A0}";
263 pub const ERROR: &str = "\u{2717}";
264 pub const INFO: &str = "\u{2139}";
265 pub const ARROW: &str = "\u{2192}";
266 pub const BULLET: &str = "\u{2022}";
267 pub const CLIPBOARD: &str = "\u{1F4CB}";
268 pub const SEARCH: &str = "\u{1F50D}";
269 pub const ROBOT: &str = "\u{1F916}";
270 pub const SAVE: &str = "\u{1F4BE}";
271}
272
273const SPINNER_FRAMES: &[char] = &[
276 '\u{280B}', '\u{2819}', '\u{2839}', '\u{2838}', '\u{283C}', '\u{2834}', '\u{2826}', '\u{2827}',
277 '\u{2807}', '\u{280F}',
278];
279
280pub fn with_spinner<F, T>(message: &str, f: F) -> T
283where
284 F: FnOnce() -> T,
285{
286 if !colors_enabled() {
288 println!("{message}");
289 return f();
290 }
291
292 let (tx, rx) = std::sync::mpsc::channel::<()>();
293 let msg = message.to_string();
294
295 let spinner = thread::spawn(move || {
296 let mut idx = 0;
297 loop {
298 if rx.try_recv().is_ok() {
299 print!("\r\x1b[K{} {}\n", icons::SUCCESS.green(), msg);
301 io::stdout().flush().ok();
302 break;
303 }
304 print!("\r{} {}", SPINNER_FRAMES[idx].cyan(), msg);
305 io::stdout().flush().ok();
306 idx = (idx + 1) % SPINNER_FRAMES.len();
307 thread::sleep(Duration::from_millis(80));
308 }
309 });
310
311 let result = f();
312 tx.send(()).ok();
313 spinner.join().ok();
314 result
315}
316
317pub fn with_spinner_result<F, T, E>(message: &str, f: F) -> Result<T, E>
319where
320 F: FnOnce() -> Result<T, E>,
321{
322 if !colors_enabled() {
323 println!("{message}");
324 return f();
325 }
326
327 let (tx, rx) = std::sync::mpsc::channel::<bool>();
328 let msg = message.to_string();
329
330 let spinner = thread::spawn(move || {
331 let mut idx = 0;
332 loop {
333 match rx.try_recv() {
334 Ok(success) => {
335 let icon = if success {
336 icons::SUCCESS.green().to_string()
337 } else {
338 icons::ERROR.red().to_string()
339 };
340 print!("\r\x1b[K{icon} {msg}\n");
341 io::stdout().flush().ok();
342 break;
343 },
344 Err(std::sync::mpsc::TryRecvError::Disconnected) => break,
345 Err(std::sync::mpsc::TryRecvError::Empty) => {},
346 }
347 print!("\r{} {}", SPINNER_FRAMES[idx].cyan(), msg);
348 io::stdout().flush().ok();
349 idx = (idx + 1) % SPINNER_FRAMES.len();
350 thread::sleep(Duration::from_millis(80));
351 }
352 });
353
354 let result = f();
355 tx.send(result.is_ok()).ok();
356 spinner.join().ok();
357 result
358}