Skip to main content

ctl_term/
terminal.rs

1//! Terminal facts: colour, colour depth, and width for one stream.
2
3use crate::color::{ColorDepth, ColorMode};
4use crate::stream::Probe;
5#[cfg(feature = "detect")]
6use crate::stream::Stream;
7
8/// The cells reserved from a detected width, so a line never touches the
9/// edge of the window.
10pub const DEFAULT_COLUMN_BUFFER: u16 = 2;
11
12/// The width a detected width never shrinks below, unless the window itself is
13/// narrower.
14pub const DEFAULT_MINIMUM_WIDTH: u16 = 20;
15
16/// The width used when the stream gives none and `COLUMNS` is unset, empty,
17/// zero, or not a number from 1 to 65535.
18pub const DEFAULT_FALLBACK_WIDTH: u16 = 80;
19
20/// How the width of a stream is decided, and every default it applies.
21///
22/// A width takes the first of these that applies:
23///
24/// 1. an explicit [`width`](Self::width), which is exact;
25/// 2. the window of the stream;
26/// 3. `COLUMNS`, when it holds a number from 1 to 65535;
27/// 4. the [fallback](Self::fallback_width).
28///
29/// The column buffer and the minimum apply only to the window and `COLUMNS`.
30/// The width that results is at least 1.
31///
32/// Each default has an override and a way to turn it off:
33///
34/// - An explicit width bypasses detection.
35/// - A column buffer of 0 reserves nothing.
36/// - A minimum of 0 sets no floor.
37/// - A fallback of `None` leaves the width undecided.
38///
39/// The column buffer reads no variable until
40/// [`column_buffer_envs`](Self::column_buffer_envs) names some.
41#[derive(Clone, Copy, Debug, Eq, PartialEq)]
42pub struct DetectOptions {
43    width: Option<u16>,
44    column_buffer: Option<u16>,
45    column_buffer_envs: &'static [&'static str],
46    minimum_width: u16,
47    fallback_width: Option<u16>,
48}
49
50impl DetectOptions {
51    /// The defaults: no explicit width, a column buffer of
52    /// [`DEFAULT_COLUMN_BUFFER`] read from no variable, a minimum of
53    /// [`DEFAULT_MINIMUM_WIDTH`], and a fallback of [`DEFAULT_FALLBACK_WIDTH`].
54    #[must_use]
55    pub const fn new() -> Self {
56        Self {
57            width: None,
58            column_buffer: None,
59            column_buffer_envs: &[],
60            minimum_width: DEFAULT_MINIMUM_WIDTH,
61            fallback_width: Some(DEFAULT_FALLBACK_WIDTH),
62        }
63    }
64
65    /// Use exactly `cells`, with no detection, buffer, or minimum. 0 counts
66    /// as 1.
67    #[must_use]
68    pub const fn width(mut self, cells: u16) -> Self {
69        self.width = Some(cells);
70        self
71    }
72
73    /// Reserve `cells` from a detected width. 0 reserves nothing. This
74    /// replaces whatever the variables of
75    /// [`column_buffer_envs`](Self::column_buffer_envs) hold.
76    #[must_use]
77    pub const fn column_buffer(mut self, cells: u16) -> Self {
78        self.column_buffer = Some(cells);
79        self
80    }
81
82    /// Read the column buffer from the first of `names` that holds a number.
83    /// An empty list reads no variable, which is the default.
84    #[must_use]
85    pub const fn column_buffer_envs(mut self, names: &'static [&'static str]) -> Self {
86        self.column_buffer_envs = names;
87        self
88    }
89
90    /// Never shrink a detected width below `cells`, unless the window is
91    /// narrower. 0 sets no floor.
92    #[must_use]
93    pub const fn minimum_width(mut self, cells: u16) -> Self {
94        self.minimum_width = cells;
95        self
96    }
97
98    /// Use `cells` when the stream gives no width and `COLUMNS` is unset,
99    /// empty, zero, or not a number from 1 to 65535.
100    /// `None` leaves the width undecided, and 0 counts as 1.
101    #[must_use]
102    pub const fn fallback_width(mut self, cells: Option<u16>) -> Self {
103        self.fallback_width = cells;
104        self
105    }
106}
107
108impl Default for DetectOptions {
109    fn default() -> Self {
110        Self::new()
111    }
112}
113
114/// What is known about one output stream: whether it is a terminal, whether
115/// to write colour, at what depth, and how wide the output is.
116///
117/// A terminal is decided once, for the stream the output goes to, before
118/// anything renders. The decision reads an environment lookup passed by the
119/// caller, so a test hands it a map and touches no process state.
120///
121/// # Colour
122///
123/// The Colour section of the [crate docs](crate) states the order and the depth
124/// rules.
125#[derive(Clone, Copy, Debug, Eq, PartialEq)]
126pub struct Terminal {
127    pub(crate) attached: bool,
128    pub(crate) color: Option<ColorDepth>,
129    pub(crate) width: Option<u16>,
130}
131
132impl Terminal {
133    /// What `stream` is, reading variables through `env`.
134    ///
135    /// `env` returns a variable's value, or `None` when it is unset. This
136    /// method asks the stream each time it is called, so two calls can differ
137    /// after a resize or a redirect.
138    /// Use [`from_probe`](Self::from_probe) to state the stream. Needs the
139    /// `detect` feature.
140    #[cfg(feature = "detect")]
141    #[must_use]
142    pub fn detect(
143        stream: Stream,
144        mode: ColorMode,
145        options: DetectOptions,
146        env: &dyn Fn(&str) -> Option<String>,
147    ) -> Self {
148        Self::from_probe(Probe::of(stream), mode, options, env)
149    }
150
151    /// [`detect`](Self::detect) reading the process environment. A value that
152    /// is not valid UTF-8 counts as set, with its invalid bytes replaced.
153    /// Needs the `detect` feature.
154    #[cfg(feature = "detect")]
155    #[must_use]
156    pub fn from_process(stream: Stream, mode: ColorMode, options: DetectOptions) -> Self {
157        Self::detect(stream, mode, options, &process_env)
158    }
159
160    /// The decision for a stream that `probe` describes.
161    ///
162    /// This is the whole decision as a function of its arguments. The other
163    /// constructors call it after they probe a stream.
164    #[must_use]
165    pub fn from_probe(
166        probe: Probe,
167        mode: ColorMode,
168        options: DetectOptions,
169        env: &dyn Fn(&str) -> Option<String>,
170    ) -> Self {
171        let lookup = Lookup(env);
172        let color = color_is_on(probe, mode, &lookup).then(|| detect_depth(&lookup));
173        Self {
174            attached: probe.is_terminal(),
175            color,
176            width: detect_width(probe, options, &lookup),
177        }
178    }
179
180    /// This terminal at `depth`, when colour is on.
181    ///
182    /// The depth otherwise follows `COLORTERM`, `TERM`, and `FORCE_COLOR`.
183    /// A terminal with colour off stays off: the mode decides whether to write
184    /// colour, and this only decides how many colours.
185    #[must_use]
186    pub fn at_depth(self, depth: ColorDepth) -> Self {
187        Self {
188            color: self.color.map(|_| depth),
189            ..self
190        }
191    }
192
193    /// Whether the stream is attached to a terminal, as opposed to a pipe or
194    /// a file.
195    #[must_use]
196    pub fn is_attached(self) -> bool {
197        self.attached
198    }
199
200    /// The colour depth, or `None` when no colour is written.
201    #[must_use]
202    pub fn color(self) -> Option<ColorDepth> {
203        self.color
204    }
205
206    /// The width to lay out to, in cells and at least 1. It is `None` when
207    /// the stream gives none, `COLUMNS` holds no number from 1 to 65535, and
208    /// the fallback is off.
209    #[must_use]
210    pub fn width(self) -> Option<u16> {
211        self.width
212    }
213}
214
215/// The process variable `name`, with bytes that are not UTF-8 replaced, so a
216/// variable holding them still counts as set.
217#[cfg(feature = "detect")]
218fn process_env(name: &str) -> Option<String> {
219    std::env::var_os(name).map(|value| value.to_string_lossy().into_owned())
220}
221
222/// An environment lookup that treats an empty value as unset.
223struct Lookup<'a>(&'a dyn Fn(&str) -> Option<String>);
224
225impl Lookup<'_> {
226    fn get(&self, name: &str) -> Option<String> {
227        (self.0)(name).filter(|value| !value.is_empty())
228    }
229
230    fn is(&self, name: &str, expected: &str) -> bool {
231        self.get(name).is_some_and(|value| value == expected)
232    }
233}
234
235fn color_is_on(probe: Probe, mode: ColorMode, env: &Lookup<'_>) -> bool {
236    match mode {
237        ColorMode::Always => true,
238        ColorMode::Never => false,
239        ColorMode::Auto => auto_color(probe, env),
240    }
241}
242
243fn auto_color(probe: Probe, env: &Lookup<'_>) -> bool {
244    if env.get("NO_COLOR").is_some() {
245        return false;
246    }
247    if env.get("CLICOLOR_FORCE").is_some_and(|value| value != "0") {
248        return true;
249    }
250    if env
251        .get("FORCE_COLOR")
252        .is_some_and(|value| value != "0" && value != "false")
253    {
254        return true;
255    }
256    if env.is("CLICOLOR", "0") || env.is("TERM", "dumb") {
257        return false;
258    }
259    probe.is_terminal()
260}
261
262fn detect_depth(env: &Lookup<'_>) -> ColorDepth {
263    let truecolor = env.is("COLORTERM", "truecolor")
264        || env.is("COLORTERM", "24bit")
265        || env.is("FORCE_COLOR", "3");
266    let palette = env
267        .get("TERM")
268        .is_some_and(|term| term.contains("256color"))
269        || env.is("FORCE_COLOR", "2");
270    if truecolor {
271        ColorDepth::TrueColor
272    } else if palette {
273        ColorDepth::Ansi256
274    } else {
275        ColorDepth::Ansi16
276    }
277}
278
279fn detect_width(probe: Probe, options: DetectOptions, env: &Lookup<'_>) -> Option<u16> {
280    let columns = options.width.or_else(|| {
281        let detected = probe.columns().or_else(|| {
282            env.get("COLUMNS")?
283                .parse::<u16>()
284                .ok()
285                .filter(|columns| *columns >= 1)
286        });
287        match detected {
288            Some(columns) => Some(effective_width(
289                columns,
290                column_buffer(options, env),
291                options.minimum_width,
292            )),
293            None => options.fallback_width,
294        }
295    });
296    columns.map(|columns| columns.max(1))
297}
298
299fn column_buffer(options: DetectOptions, env: &Lookup<'_>) -> u16 {
300    options
301        .column_buffer
302        .or_else(|| {
303            options
304                .column_buffer_envs
305                .iter()
306                .filter_map(|name| env.get(name))
307                .find_map(|raw| raw.parse().ok())
308        })
309        .unwrap_or(DEFAULT_COLUMN_BUFFER)
310}
311
312fn effective_width(width: u16, buffer: u16, minimum: u16) -> u16 {
313    width.saturating_sub(buffer).max(minimum.min(width))
314}