1use std::io::{self, IsTerminal};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum TerminalColorSupport {
12 None,
13 Ansi16,
14 TrueColor,
15}
16
17impl TerminalColorSupport {
18 pub fn detect_from_env() -> Self {
19 Self::detect_with(|key| std::env::var(key).ok())
20 }
21
22 fn detect_with(get_env: impl Fn(&str) -> Option<String>) -> Self {
23 if get_env("NO_COLOR").is_some() {
24 return Self::None;
25 }
26
27 let colorterm = get_env("COLORTERM")
28 .unwrap_or_default()
29 .to_ascii_lowercase();
30 if colorterm.contains("truecolor") || colorterm.contains("24bit") {
31 return Self::TrueColor;
32 }
33
34 let term = get_env("TERM").unwrap_or_default().to_ascii_lowercase();
35 if term.contains("truecolor") || term.contains("24bit") || term.contains("direct") {
36 return Self::TrueColor;
37 }
38
39 if get_env("TERM_PROGRAM")
40 .unwrap_or_default()
41 .eq_ignore_ascii_case("wezterm")
42 {
43 return Self::TrueColor;
44 }
45
46 if get_env("KITTY_WINDOW_ID").is_some() {
47 return Self::TrueColor;
48 }
49
50 Self::Ansi16
51 }
52
53 pub fn is_truecolor(self) -> bool {
54 matches!(self, Self::TrueColor)
55 }
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum RenderMode {
61 Stream,
64 Dashboard,
66}
67
68const MIN_DASHBOARD_COLS: u16 = 80;
70const MIN_DASHBOARD_ROWS: u16 = 24;
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub struct TerminalInfo {
76 pub is_tty: bool,
77 pub cols: u16,
78 pub rows: u16,
79 pub color_support: TerminalColorSupport,
80}
81
82impl TerminalInfo {
83 pub fn detect() -> Self {
85 let is_tty = io::stdout().is_terminal();
86 let (cols, rows) = crossterm::terminal::size().unwrap_or((0, 0));
87 let color_support = if is_tty {
88 TerminalColorSupport::detect_from_env()
89 } else {
90 TerminalColorSupport::None
91 };
92 Self {
93 is_tty,
94 cols,
95 rows,
96 color_support,
97 }
98 }
99
100 pub fn supports_dashboard(&self) -> bool {
102 self.is_tty && self.cols >= MIN_DASHBOARD_COLS && self.rows >= MIN_DASHBOARD_ROWS
103 }
104}
105
106pub fn select_render_mode(
113 info: &TerminalInfo,
114 force_stream: bool,
115 force_tui: bool,
116) -> Result<RenderMode, RenderModeError> {
117 if force_stream {
118 return Ok(RenderMode::Stream);
119 }
120
121 if force_tui {
122 if !info.supports_dashboard() {
123 return Err(RenderModeError::TerminalTooSmall {
124 cols: info.cols,
125 rows: info.rows,
126 });
127 }
128 return Ok(RenderMode::Dashboard);
129 }
130
131 if info.supports_dashboard() {
133 Ok(RenderMode::Dashboard)
134 } else {
135 Ok(RenderMode::Stream)
136 }
137}
138
139#[derive(Debug, thiserror::Error)]
141pub enum RenderModeError {
142 #[error("terminal too small for dashboard mode ({cols}x{rows}, need {MIN_DASHBOARD_COLS}x{MIN_DASHBOARD_ROWS})")]
143 TerminalTooSmall { cols: u16, rows: u16 },
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149
150 fn tty_large() -> TerminalInfo {
151 TerminalInfo {
152 is_tty: true,
153 cols: 120,
154 rows: 40,
155 color_support: TerminalColorSupport::Ansi16,
156 }
157 }
158
159 fn tty_small() -> TerminalInfo {
160 TerminalInfo {
161 is_tty: true,
162 cols: 60,
163 rows: 20,
164 color_support: TerminalColorSupport::Ansi16,
165 }
166 }
167
168 fn pipe() -> TerminalInfo {
169 TerminalInfo {
170 is_tty: false,
171 cols: 120,
172 rows: 40,
173 color_support: TerminalColorSupport::None,
174 }
175 }
176
177 fn tty_narrow() -> TerminalInfo {
178 TerminalInfo {
179 is_tty: true,
180 cols: 79,
181 rows: 40,
182 color_support: TerminalColorSupport::Ansi16,
183 }
184 }
185
186 fn tty_short() -> TerminalInfo {
187 TerminalInfo {
188 is_tty: true,
189 cols: 120,
190 rows: 23,
191 color_support: TerminalColorSupport::Ansi16,
192 }
193 }
194
195 #[test]
198 fn large_tty_supports_dashboard() {
199 assert!(tty_large().supports_dashboard());
200 }
201
202 #[test]
203 fn pipe_does_not_support_dashboard() {
204 assert!(!pipe().supports_dashboard());
205 }
206
207 #[test]
208 fn small_tty_does_not_support_dashboard() {
209 assert!(!tty_small().supports_dashboard());
210 }
211
212 #[test]
213 fn narrow_tty_does_not_support_dashboard() {
214 assert!(!tty_narrow().supports_dashboard());
215 }
216
217 #[test]
218 fn short_tty_does_not_support_dashboard() {
219 assert!(!tty_short().supports_dashboard());
220 }
221
222 #[test]
223 fn exact_minimum_supports_dashboard() {
224 let info = TerminalInfo {
225 is_tty: true,
226 cols: MIN_DASHBOARD_COLS,
227 rows: MIN_DASHBOARD_ROWS,
228 color_support: TerminalColorSupport::Ansi16,
229 };
230 assert!(info.supports_dashboard());
231 }
232
233 #[test]
234 fn detects_truecolor_from_colorterm() {
235 let detected = TerminalColorSupport::detect_with(|key| match key {
236 "COLORTERM" => Some("truecolor".to_string()),
237 _ => None,
238 });
239 assert_eq!(detected, TerminalColorSupport::TrueColor);
240 }
241
242 #[test]
243 fn detects_truecolor_from_term_suffix() {
244 let detected = TerminalColorSupport::detect_with(|key| match key {
245 "TERM" => Some("xterm-direct".to_string()),
246 _ => None,
247 });
248 assert_eq!(detected, TerminalColorSupport::TrueColor);
249 }
250
251 #[test]
252 fn no_color_disables_color_output() {
253 let detected = TerminalColorSupport::detect_with(|key| match key {
254 "NO_COLOR" => Some("1".to_string()),
255 "COLORTERM" => Some("truecolor".to_string()),
256 _ => None,
257 });
258 assert_eq!(detected, TerminalColorSupport::None);
259 }
260
261 #[test]
264 fn force_stream_always_stream() {
265 let mode = select_render_mode(&tty_large(), true, false).unwrap();
266 assert_eq!(mode, RenderMode::Stream);
267 }
268
269 #[test]
270 fn force_tui_returns_dashboard_on_large_tty() {
271 let mode = select_render_mode(&tty_large(), false, true).unwrap();
272 assert_eq!(mode, RenderMode::Dashboard);
273 }
274
275 #[test]
276 fn force_tui_errors_on_small_terminal() {
277 let result = select_render_mode(&tty_small(), false, true);
278 assert!(result.is_err());
279 assert!(result.unwrap_err().to_string().contains("too small"));
280 }
281
282 #[test]
283 fn force_stream_wins_over_tui() {
284 let mode = select_render_mode(&tty_large(), true, true).unwrap();
286 assert_eq!(mode, RenderMode::Stream);
287 }
288
289 #[test]
290 fn auto_detect_pipe_returns_stream() {
291 let mode = select_render_mode(&pipe(), false, false).unwrap();
292 assert_eq!(mode, RenderMode::Stream);
293 }
294
295 #[test]
296 fn auto_detect_small_tty_returns_stream() {
297 let mode = select_render_mode(&tty_small(), false, false).unwrap();
298 assert_eq!(mode, RenderMode::Stream);
299 }
300
301 #[test]
302 fn auto_detect_large_tty_returns_dashboard() {
303 let mode = select_render_mode(&tty_large(), false, false).unwrap();
304 assert_eq!(mode, RenderMode::Dashboard);
305 }
306
307 #[test]
308 fn auto_detect_narrow_returns_stream() {
309 let mode = select_render_mode(&tty_narrow(), false, false).unwrap();
310 assert_eq!(mode, RenderMode::Stream);
311 }
312
313 #[test]
314 fn auto_detect_short_returns_stream() {
315 let mode = select_render_mode(&tty_short(), false, false).unwrap();
316 assert_eq!(mode, RenderMode::Stream);
317 }
318
319 #[test]
320 fn terminal_info_detect_does_not_panic() {
321 let _info = TerminalInfo::detect();
323 }
324}