track_core/
terminal_ui.rs1use std::env;
2use std::io::{self, IsTerminal};
3
4use owo_colors::OwoColorize;
5
6#[derive(Clone, Copy)]
7pub enum SummaryTone {
8 Success,
9 Info,
10}
11
12#[derive(Clone, Copy)]
13pub enum ValueTone {
14 Plain,
15 Path,
16 PriorityHigh,
17 PriorityMedium,
18 PriorityLow,
19 StatusOpen,
20 StatusClosed,
21}
22
23pub fn format_summary(
24 title: &str,
25 tone: SummaryTone,
26 rows: &[(&str, String, ValueTone)],
27) -> String {
28 let mut lines = vec![style_title(title, tone)];
29 let label_width = rows
30 .iter()
31 .map(|(label, _, _)| label.len())
32 .max()
33 .unwrap_or(0);
34
35 for (label, value, value_tone) in rows {
36 lines.push(format_summary_row(label, value, *value_tone, label_width));
37 }
38
39 lines.join("\n")
40}
41
42pub fn format_note(label: &str, value: &str) -> String {
43 let raw_label = format!("{label:<8}");
44 if should_color() {
45 format!(" {} {}", raw_label.bold().blue(), value.dimmed())
46 } else {
47 format!(" {raw_label} {value}")
48 }
49}
50
51pub fn format_prompt_label(label: &str, default_value: Option<&str>) -> String {
52 match default_value.filter(|value| !value.trim().is_empty()) {
53 Some(default_value) => format!("{label} [{default_value}]"),
54 None => label.to_owned(),
55 }
56}
57
58fn format_summary_row(
59 label: &str,
60 value: &str,
61 value_tone: ValueTone,
62 label_width: usize,
63) -> String {
64 let raw_label = format!("{label:<label_width$}", label_width = label_width);
65 let rendered_label = if should_color() {
66 raw_label.bold().blue().to_string()
67 } else {
68 raw_label
69 };
70
71 format!(" {rendered_label} {}", style_value(value, value_tone))
72}
73
74fn style_title(title: &str, tone: SummaryTone) -> String {
75 if !should_color() {
76 return title.to_owned();
77 }
78
79 match tone {
80 SummaryTone::Success => title.bold().green().to_string(),
81 SummaryTone::Info => title.bold().cyan().to_string(),
82 }
83}
84
85fn style_value(value: &str, tone: ValueTone) -> String {
86 if !should_color() {
87 return value.to_owned();
88 }
89
90 match tone {
91 ValueTone::Plain => value.to_owned(),
92 ValueTone::Path => value.cyan().to_string(),
93 ValueTone::PriorityHigh => value.bold().red().to_string(),
94 ValueTone::PriorityMedium => value.bold().yellow().to_string(),
95 ValueTone::PriorityLow => value.bold().green().to_string(),
96 ValueTone::StatusOpen => value.bold().green().to_string(),
97 ValueTone::StatusClosed => value.bold().magenta().to_string(),
98 }
99}
100
101fn should_color() -> bool {
102 env::var_os("NO_COLOR").is_none() && io::stdout().is_terminal()
103}
104
105#[cfg(test)]
106mod tests {
107 use super::{format_note, format_prompt_label, format_summary, SummaryTone, ValueTone};
108
109 #[test]
110 fn renders_plain_prompt_labels_with_defaults() {
111 assert_eq!(
112 format_prompt_label("Model file", Some("~/.models/parser.gguf")),
113 "Model file [~/.models/parser.gguf]"
114 );
115 }
116
117 #[test]
118 fn renders_plain_note_without_a_terminal() {
119 assert_eq!(
120 format_note("Enter", "keep current values"),
121 " Enter keep current values"
122 );
123 }
124
125 #[test]
126 fn renders_plain_summary_without_a_terminal() {
127 let rendered = format_summary(
128 "Created task",
129 SummaryTone::Success,
130 &[
131 ("Project", "project-x".to_owned(), ValueTone::Plain),
132 (
133 "File",
134 "~/.track/issues/project-x/open/task.md".to_owned(),
135 ValueTone::Path,
136 ),
137 ],
138 );
139
140 assert_eq!(
141 rendered,
142 "Created task\n Project project-x\n File ~/.track/issues/project-x/open/task.md"
143 );
144 }
145}