1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::fmt;
5use std::{
6 env,
7 io::{self, IsTerminal},
8};
9
10pub mod prelude {
12 pub use crate::{
13 ColorSupport, Interactivity, TerminalDimensionError, TerminalHeight, TerminalSize,
14 TerminalWidth, detect_color_support, stderr_interactivity, stdin_interactivity,
15 stdout_interactivity,
16 };
17}
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub enum TerminalDimensionError {
22 ZeroWidth,
24 ZeroHeight,
26}
27
28impl fmt::Display for TerminalDimensionError {
29 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
30 match self {
31 Self::ZeroWidth => formatter.write_str("terminal width must be greater than zero"),
32 Self::ZeroHeight => formatter.write_str("terminal height must be greater than zero"),
33 }
34 }
35}
36
37impl std::error::Error for TerminalDimensionError {}
38
39#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
41pub struct TerminalWidth {
42 columns: u16,
43}
44
45impl TerminalWidth {
46 pub const fn new(columns: u16) -> Result<Self, TerminalDimensionError> {
52 if columns == 0 {
53 Err(TerminalDimensionError::ZeroWidth)
54 } else {
55 Ok(Self { columns })
56 }
57 }
58
59 #[must_use]
61 pub const fn columns(self) -> u16 {
62 self.columns
63 }
64}
65
66#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
68pub struct TerminalHeight {
69 rows: u16,
70}
71
72impl TerminalHeight {
73 pub const fn new(rows: u16) -> Result<Self, TerminalDimensionError> {
79 if rows == 0 {
80 Err(TerminalDimensionError::ZeroHeight)
81 } else {
82 Ok(Self { rows })
83 }
84 }
85
86 #[must_use]
88 pub const fn rows(self) -> u16 {
89 self.rows
90 }
91}
92
93#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
95pub struct TerminalSize {
96 width: TerminalWidth,
97 height: TerminalHeight,
98}
99
100impl TerminalSize {
101 #[must_use]
103 pub const fn new(width: TerminalWidth, height: TerminalHeight) -> Self {
104 Self { width, height }
105 }
106
107 pub const fn try_new(columns: u16, rows: u16) -> Result<Self, TerminalDimensionError> {
113 Ok(Self::new(
114 match TerminalWidth::new(columns) {
115 Ok(width) => width,
116 Err(error) => return Err(error),
117 },
118 match TerminalHeight::new(rows) {
119 Ok(height) => height,
120 Err(error) => return Err(error),
121 },
122 ))
123 }
124
125 #[must_use]
127 pub const fn width(self) -> TerminalWidth {
128 self.width
129 }
130
131 #[must_use]
133 pub const fn height(self) -> TerminalHeight {
134 self.height
135 }
136}
137
138#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
140pub enum ColorSupport {
141 NoColor,
143 Basic,
145 Ansi256,
147 TrueColor,
149}
150
151impl ColorSupport {
152 #[must_use]
154 pub fn from_env_values(
155 no_color: Option<&str>,
156 term: Option<&str>,
157 colorterm: Option<&str>,
158 ) -> Self {
159 if no_color.is_some() {
160 return Self::NoColor;
161 }
162
163 if colorterm.is_some_and(|value| {
164 value.eq_ignore_ascii_case("truecolor") || value.eq_ignore_ascii_case("24bit")
165 }) {
166 return Self::TrueColor;
167 }
168
169 match term {
170 Some(value) if value.eq_ignore_ascii_case("dumb") => Self::NoColor,
171 Some(value) if value.contains("256color") => Self::Ansi256,
172 Some("") | None => Self::NoColor,
173 Some(_) => Self::Basic,
174 }
175 }
176}
177
178#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
180pub enum Interactivity {
181 Interactive,
183 NonInteractive,
185}
186
187impl Interactivity {
188 #[must_use]
190 pub const fn from_bool(is_terminal: bool) -> Self {
191 if is_terminal {
192 Self::Interactive
193 } else {
194 Self::NonInteractive
195 }
196 }
197
198 #[must_use]
200 pub const fn is_interactive(self) -> bool {
201 matches!(self, Self::Interactive)
202 }
203}
204
205#[must_use]
207pub fn detect_color_support() -> ColorSupport {
208 let no_color = env::var_os("NO_COLOR").map(|_| "");
209 let term = env::var("TERM").ok();
210 let colorterm = env::var("COLORTERM").ok();
211
212 ColorSupport::from_env_values(no_color, term.as_deref(), colorterm.as_deref())
213}
214
215#[must_use]
217pub fn stdin_interactivity() -> Interactivity {
218 Interactivity::from_bool(io::stdin().is_terminal())
219}
220
221#[must_use]
223pub fn stdout_interactivity() -> Interactivity {
224 Interactivity::from_bool(io::stdout().is_terminal())
225}
226
227#[must_use]
229pub fn stderr_interactivity() -> Interactivity {
230 Interactivity::from_bool(io::stderr().is_terminal())
231}
232
233#[cfg(test)]
234mod tests {
235 use super::{
236 ColorSupport, Interactivity, TerminalDimensionError, TerminalHeight, TerminalSize,
237 TerminalWidth,
238 };
239
240 #[test]
241 fn validates_terminal_dimensions() -> Result<(), TerminalDimensionError> {
242 let size = TerminalSize::try_new(80, 24)?;
243
244 assert_eq!(size.width().columns(), 80);
245 assert_eq!(size.height().rows(), 24);
246 assert_eq!(
247 TerminalWidth::new(0),
248 Err(TerminalDimensionError::ZeroWidth)
249 );
250 assert_eq!(
251 TerminalHeight::new(0),
252 Err(TerminalDimensionError::ZeroHeight)
253 );
254 Ok(())
255 }
256
257 #[test]
258 fn infers_color_support_from_env_values() {
259 assert_eq!(
260 ColorSupport::from_env_values(Some("1"), Some("xterm-256color"), Some("truecolor")),
261 ColorSupport::NoColor
262 );
263 assert_eq!(
264 ColorSupport::from_env_values(None, Some("xterm-256color"), None),
265 ColorSupport::Ansi256
266 );
267 assert_eq!(
268 ColorSupport::from_env_values(None, Some("xterm"), Some("truecolor")),
269 ColorSupport::TrueColor
270 );
271 assert_eq!(
272 ColorSupport::from_env_values(None, Some("dumb"), None),
273 ColorSupport::NoColor
274 );
275 }
276
277 #[test]
278 fn converts_interactivity_from_bool() {
279 assert!(Interactivity::from_bool(true).is_interactive());
280 assert!(!Interactivity::from_bool(false).is_interactive());
281 }
282}