Skip to main content

use_terminal/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::fmt;
5use std::{
6    env,
7    io::{self, IsTerminal},
8};
9
10/// Commonly used terminal primitives.
11pub 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/// Validation errors for terminal dimensions.
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub enum TerminalDimensionError {
22    /// Width must be nonzero.
23    ZeroWidth,
24    /// Height must be nonzero.
25    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/// A nonzero terminal width in columns.
40#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
41pub struct TerminalWidth {
42    columns: u16,
43}
44
45impl TerminalWidth {
46    /// Creates a terminal width.
47    ///
48    /// # Errors
49    ///
50    /// Returns [`TerminalDimensionError::ZeroWidth`] when `columns` is zero.
51    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    /// Returns the width in columns.
60    #[must_use]
61    pub const fn columns(self) -> u16 {
62        self.columns
63    }
64}
65
66/// A nonzero terminal height in rows.
67#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
68pub struct TerminalHeight {
69    rows: u16,
70}
71
72impl TerminalHeight {
73    /// Creates a terminal height.
74    ///
75    /// # Errors
76    ///
77    /// Returns [`TerminalDimensionError::ZeroHeight`] when `rows` is zero.
78    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    /// Returns the height in rows.
87    #[must_use]
88    pub const fn rows(self) -> u16 {
89        self.rows
90    }
91}
92
93/// A terminal size value made from validated dimensions.
94#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
95pub struct TerminalSize {
96    width: TerminalWidth,
97    height: TerminalHeight,
98}
99
100impl TerminalSize {
101    /// Creates a terminal size from validated dimensions.
102    #[must_use]
103    pub const fn new(width: TerminalWidth, height: TerminalHeight) -> Self {
104        Self { width, height }
105    }
106
107    /// Creates a terminal size from raw column and row counts.
108    ///
109    /// # Errors
110    ///
111    /// Returns [`TerminalDimensionError`] when either dimension is zero.
112    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    /// Returns the validated width.
126    #[must_use]
127    pub const fn width(self) -> TerminalWidth {
128        self.width
129    }
130
131    /// Returns the validated height.
132    #[must_use]
133    pub const fn height(self) -> TerminalHeight {
134        self.height
135    }
136}
137
138/// Primitive terminal color capability.
139#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
140pub enum ColorSupport {
141    /// Color output should not be used.
142    NoColor,
143    /// Basic ANSI color support is likely available.
144    Basic,
145    /// 256-color ANSI support is likely available.
146    Ansi256,
147    /// 24-bit truecolor support is likely available.
148    TrueColor,
149}
150
151impl ColorSupport {
152    /// Infers color support from environment variable values.
153    #[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/// Primitive stream interactivity state.
179#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
180pub enum Interactivity {
181    /// The stream appears to be attached to a terminal.
182    Interactive,
183    /// The stream does not appear to be attached to a terminal.
184    NonInteractive,
185}
186
187impl Interactivity {
188    /// Converts a boolean terminal result into an interactivity value.
189    #[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    /// Returns whether the stream is interactive.
199    #[must_use]
200    pub const fn is_interactive(self) -> bool {
201        matches!(self, Self::Interactive)
202    }
203}
204
205/// Detects color support using `NO_COLOR`, `TERM`, and `COLORTERM`.
206#[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/// Detects whether stdin is attached to a terminal.
216#[must_use]
217pub fn stdin_interactivity() -> Interactivity {
218    Interactivity::from_bool(io::stdin().is_terminal())
219}
220
221/// Detects whether stdout is attached to a terminal.
222#[must_use]
223pub fn stdout_interactivity() -> Interactivity {
224    Interactivity::from_bool(io::stdout().is_terminal())
225}
226
227/// Detects whether stderr is attached to a terminal.
228#[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}