Skip to main content

uncurses/renderer/
caps.rs

1//! Terminal capability flags used by the renderer.
2//!
3//! ## Purpose
4//!
5//! [`Optimizations`] is not a feature wishlist; it is the renderer's
6//! contract for which byte sequences are safe to emit for the current
7//! terminal and line discipline. Each flag unlocks a family of shorter
8//! sequences. When a flag is absent, the renderer falls back to more
9//! conservative cursor movement or explicit cell writes.
10//!
11//! ## Detection
12//!
13//! The built-in detector maps `$TERM` families to conservative baseline
14//! sets. Unknown, empty, and `dumb` terminals use [`Optimizations::none`].
15//! A missing `$TERM` uses [`Optimizations::default`] so in-memory tests
16//! and sinks without environment information keep a useful baseline.
17//!
18//! ## Line-discipline flags
19//!
20//! [`Optimizations::TABS`], [`Optimizations::BS`], and
21//! [`Optimizations::ONLCR`] describe the host's output processing, not
22//! the terminal's own abilities, so no baseline here carries them: a
23//! `\t` is only usable if the line discipline passes it through, which
24//! `$TERM` cannot tell you. A [`Screen`](crate::screen::Screen) enables
25//! `TABS` and `BS` on *every* raw-mode entry —
26//! [`init`](crate::screen::Screen::init) and
27//! [`resume`](crate::screen::Screen::resume) alike — because raw mode
28//! turns the host's output processing off, which is exactly what makes
29//! `\t` and `\x08` safe to emit.
30//!
31//! `ONLCR` is never granted: raw mode is also what makes it false. It
32//! stays opt-in for callers who know their output crosses a layer that
33//! turns `\n` into `\r\n` anyway.
34//!
35//! Driving a [`Renderer`] yourself, or keeping the host in cooked mode?
36//! Then the answer is yours to supply: use [`Optimizations::with_tabs`],
37//! [`Optimizations::with_bs`], and [`Optimizations::with_onlcr`]. Leaving
38//! all three off is always safe — the renderer then emits escape
39//! sequences that no line discipline can rewrite.
40//!
41//! [`Renderer`]: crate::renderer::Renderer
42
43use bitflags::bitflags;
44
45use crate::terminal::Env;
46
47bitflags! {
48    /// Terminal capabilities the renderer may use for shorter output.
49    ///
50    /// # Usage
51    ///
52    /// Start from a detector result such as [`Optimizations::from_env`]
53    /// or a baseline such as [`Optimizations::xterm`], then use the
54    /// `with_*` methods to toggle assumptions confirmed by probing or
55    /// terminal-mode setup.
56    ///
57    /// Flag names follow the short capability names used by `infocmp`
58    /// where they exist. `BS` and `ONLCR` are not terminfo caps; they
59    /// describe control-character and output-processing behavior.
60    ///
61    /// [`TABS`](Self::TABS), [`BS`](Self::BS), and
62    /// [`ONLCR`](Self::ONLCR) are the odd ones out: they describe the
63    /// *host*, not the terminal. A [`Screen`](crate::screen::Screen)
64    /// enables `TABS` and `BS` on every raw-mode entry and never touches
65    /// `ONLCR`. See the module docs.
66    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
67    pub struct Optimizations: u32 {
68        /// Terminal supports ECH (Erase Characters, `CSI Ps X`) for
69        /// clearing a run on the current row.
70        const ECH    = 1 <<  0;
71        /// Terminal supports REP (Repeat preceding character,
72        /// `CSI Ps b`) for compact repeated ASCII glyphs.
73        const REP    = 1 <<  1;
74        /// Terminal supports ICH (Insert Characters, `CSI Ps @`) for
75        /// opening cells within a row.
76        const ICH    = 1 <<  2;
77        /// Terminal supports DCH (Delete Characters, `CSI Ps P`) for
78        /// removing cells within a row.
79        const DCH    = 1 <<  3;
80        /// Terminal supports scroll regions (DECSTBM; terminfo `csr`).
81        const CSR    = 1 <<  4;
82        /// Terminal supports SU/SD (Scroll Up/Down, `CSI Ps S` /
83        /// `CSI Ps T`) for moving full scroll regions.
84        const SU_SD  = 1 <<  5;
85        /// Terminal supports IL/DL (Insert/Delete Line, `CSI Ps L` /
86        /// `CSI Ps M`) for line-level scroll fallbacks.
87        const IL_DL  = 1 <<  6;
88        /// Terminal supports BCE (Background Color Erase): erase
89        /// operations paint with the active background color.
90        ///
91        /// Also governs cursor motion. An inline downward move is
92        /// emitted as a literal `\n`, which scrolls the host when the
93        /// destination row does not exist yet; with BCE that scroll
94        /// paints the exposed row with the active background, so the
95        /// planner resets the pen first. See `PenPolicy`.
96        const BCE    = 1 <<  7;
97        /// Terminal supports CHA (Cursor Horizontal Absolute,
98        /// `CSI Ps G`) for absolute column moves.
99        const CHA    = 1 <<  8;
100        /// Terminal supports HPA (Horizontal Position Absolute,
101        /// `CSI Ps \``) for absolute column moves.
102        const HPA    = 1 <<  9;
103        /// Terminal supports VPA (Vertical Position Absolute,
104        /// `CSI Ps d`) for absolute row moves.
105        const VPA    = 1 << 10;
106        /// Literal tab bytes move to configured hardware tab stops.
107        const TABS   = 1 << 11;
108        /// Terminal supports CBT (Cursor Backward Tab, `CSI Ps Z`).
109        const CBT    = 1 << 12;
110        /// Terminal supports CHT (Cursor Horizontal Tab, `CSI Ps I`).
111        const CHT    = 1 << 13;
112        /// Terminal supports BS (the backspace control character,
113        /// `\x08`) for cursor-left-by-one.
114        const BS     = 1 << 14;
115        /// Whether the terminal currently maps `\n` to `\r\n`
116        /// (termios ONLCR). In raw mode this is unset and `\n` only
117        /// moves the cursor down without resetting the column.
118        const ONLCR  = 1 << 15;
119    }
120}
121
122impl Default for Optimizations {
123    /// The default is [`Optimizations::xterm`] — the modern baseline
124    /// for the overwhelming majority of terminals reachable from a
125    /// generic `TERM=xterm-256color` session.
126    fn default() -> Self {
127        Self::xterm()
128    }
129}
130
131impl Optimizations {
132    /// Return the most conservative useful capability set.
133    ///
134    /// Every escape-sequence optimization is disabled. Use this for
135    /// unknown or genuinely capability-limited terminals when direct
136    /// cell output is safer than specialized control sequences.
137    pub const fn none() -> Self {
138        Self::empty()
139    }
140
141    /// Return the modern full-feature baseline.
142    ///
143    /// Enables every renderer optimization a terminal can advertise.
144    /// The line-discipline flags are absent: see the module docs.
145    pub const fn modern() -> Self {
146        Self::ECH
147            .union(Self::REP)
148            .union(Self::ICH)
149            .union(Self::DCH)
150            .union(Self::CSR)
151            .union(Self::SU_SD)
152            .union(Self::IL_DL)
153            .union(Self::BCE)
154            .union(Self::CHA)
155            .union(Self::HPA)
156            .union(Self::VPA)
157            .union(Self::CBT)
158            .union(Self::CHT)
159    }
160
161    /// Return the xterm-compatible conservative baseline.
162    ///
163    /// Compared to [`Self::modern`], `HPA`,
164    /// `CHT`, and `REP` are off:
165    /// - `HPA`: konsole and several xterm-compatible terminals lack
166    ///   HPA; xterm-256color terminfo defines HPA via the same
167    ///   sequence as CHA, so CHA is the safer choice.
168    /// - `CHT`: forward-tab support is historically inconsistent
169    ///   across xterm-compatible emulators.
170    /// - `REP`: REP is not universally implemented across the
171    ///   xterm-compatible family.
172    pub const fn xterm() -> Self {
173        Self::modern().difference(Self::HPA.union(Self::CHT).union(Self::REP))
174    }
175
176    /// Return the VT100/VT102 baseline.
177    ///
178    /// Predates the xterm extensions for
179    /// absolute positioning (CHA/HPA/VPA), ECH, REP, BCE, SU/SD, and
180    /// CBT, but supports DECSTBM and, on the VT102, the ICH/DCH/IL/DL
181    /// editing pairs.
182    pub const fn vt100() -> Self {
183        Self::ICH
184            .union(Self::DCH)
185            .union(Self::CSR)
186            .union(Self::IL_DL)
187    }
188
189    /// Return the Linux console baseline.
190    ///
191    /// The kernel's terminal driver
192    /// implements a narrow subset of ECMA-48 — only absolute
193    /// positioning (CHA/HPA/VPA), ECH, and ICH.
194    /// See `console_codes(4)`.
195    pub const fn linux() -> Self {
196        Self::ECH
197            .union(Self::ICH)
198            .union(Self::CHA)
199            .union(Self::HPA)
200            .union(Self::VPA)
201    }
202
203    /// Return the GNU screen baseline, derived from
204    /// `infocmp -x1 screen-256color`. screen multiplexes onto the
205    /// host terminal and only re-advertises a conservative subset:
206    /// no `BCE`, `ECH`, `REP`, `CHA`, or `CHT`.
207    pub const fn screen() -> Self {
208        Self::ICH
209            .union(Self::DCH)
210            .union(Self::CSR)
211            .union(Self::SU_SD)
212            .union(Self::IL_DL)
213            .union(Self::HPA)
214            .union(Self::VPA)
215            .union(Self::CBT)
216    }
217
218    /// Return `self` with hardware tab support (`TABS`) toggled.
219    ///
220    /// Disable when the
221    /// receiving terminal is in cooked mode without `TAB0` set on
222    /// `c_oflag` and `\t` would otherwise be expanded to spaces.
223    ///
224    /// A [`Screen`](crate::screen::Screen) enables this on every raw-mode
225    /// entry, since raw mode is what makes `\t` safe, so setting it here
226    /// matters only when driving a renderer directly.
227    #[must_use]
228    pub const fn with_tabs(self, enabled: bool) -> Self {
229        self.with_flag(Self::TABS, enabled)
230    }
231
232    /// Return `self` with backspace-character support (`BS`) toggled.
233    ///
234    /// Disable when the
235    /// receiving terminal does not interpret `\x08` as cursor-left
236    /// by one cell.
237    ///
238    /// A [`Screen`](crate::screen::Screen) enables this on every raw-mode
239    /// entry, since raw mode is what makes `\x08` safe, so setting it here
240    /// matters only when driving a renderer directly.
241    #[must_use]
242    pub const fn with_bs(self, enabled: bool) -> Self {
243        self.with_flag(Self::BS, enabled)
244    }
245
246    /// Return `self` with the `\n` → `\r\n` assumption (`ONLCR`)
247    /// toggled.
248    ///
249    /// Enable when the terminal is in cooked mode with `ONLCR` set so a
250    /// newline both advances a row and resets the column.
251    ///
252    /// A [`Screen`](crate::screen::Screen) never sets this — raw mode
253    /// clears `OPOST`, so `\n` carries no carriage return — but it never
254    /// clears it either. It is yours to opt into and yours to keep.
255    #[must_use]
256    pub const fn with_onlcr(self, enabled: bool) -> Self {
257        self.with_flag(Self::ONLCR, enabled)
258    }
259
260    /// Return `self` with erase-character (`ECH`) support toggled.
261    #[must_use]
262    pub const fn with_ech(self, enabled: bool) -> Self {
263        self.with_flag(Self::ECH, enabled)
264    }
265
266    /// Return `self` with repeat-character (`REP`) support toggled.
267    #[must_use]
268    pub const fn with_rep(self, enabled: bool) -> Self {
269        self.with_flag(Self::REP, enabled)
270    }
271
272    /// Return `self` with insert-character (`ICH`) support toggled.
273    #[must_use]
274    pub const fn with_ich(self, enabled: bool) -> Self {
275        self.with_flag(Self::ICH, enabled)
276    }
277
278    /// Return `self` with delete-character (`DCH`) support toggled.
279    #[must_use]
280    pub const fn with_dch(self, enabled: bool) -> Self {
281        self.with_flag(Self::DCH, enabled)
282    }
283
284    /// Return `self` with DECSTBM scroll-region (`CSR`) support toggled.
285    #[must_use]
286    pub const fn with_csr(self, enabled: bool) -> Self {
287        self.with_flag(Self::CSR, enabled)
288    }
289
290    /// Return `self` with scroll-up/scroll-down (`SU_SD`) support toggled.
291    #[must_use]
292    pub const fn with_su_sd(self, enabled: bool) -> Self {
293        self.with_flag(Self::SU_SD, enabled)
294    }
295
296    /// Return `self` with insert/delete-line (`IL_DL`) support toggled.
297    #[must_use]
298    pub const fn with_il_dl(self, enabled: bool) -> Self {
299        self.with_flag(Self::IL_DL, enabled)
300    }
301
302    /// Return `self` with background-color-erase (`BCE`) support toggled.
303    #[must_use]
304    pub const fn with_bce(self, enabled: bool) -> Self {
305        self.with_flag(Self::BCE, enabled)
306    }
307
308    /// Return `self` with cursor-horizontal-absolute (`CHA`) support toggled.
309    #[must_use]
310    pub const fn with_cha(self, enabled: bool) -> Self {
311        self.with_flag(Self::CHA, enabled)
312    }
313
314    /// Return `self` with horizontal-position-absolute (`HPA`) support toggled.
315    #[must_use]
316    pub const fn with_hpa(self, enabled: bool) -> Self {
317        self.with_flag(Self::HPA, enabled)
318    }
319
320    /// Return `self` with vertical-position-absolute (`VPA`) support toggled.
321    #[must_use]
322    pub const fn with_vpa(self, enabled: bool) -> Self {
323        self.with_flag(Self::VPA, enabled)
324    }
325
326    /// Return `self` with cursor-backward-tab (`CBT`) support toggled.
327    #[must_use]
328    pub const fn with_cbt(self, enabled: bool) -> Self {
329        self.with_flag(Self::CBT, enabled)
330    }
331
332    /// Return `self` with cursor-horizontal-tab (`CHT`) support toggled.
333    #[must_use]
334    pub const fn with_cht(self, enabled: bool) -> Self {
335        self.with_flag(Self::CHT, enabled)
336    }
337
338    /// Const-friendly helper used by the `with_*` builders.
339    const fn with_flag(self, flag: Self, enabled: bool) -> Self {
340        if enabled {
341            self.union(flag)
342        } else {
343            self.difference(flag)
344        }
345    }
346
347    /// Derive an optimization set from a `TERM` value.
348    ///
349    /// # Parameters
350    ///
351    /// - `term`: terminal name, usually from `$TERM`.
352    ///
353    /// # Returns
354    ///
355    /// A baseline capability set for the terminal family. Unknown,
356    /// empty, and `dumb` values return [`Self::none`].
357    pub fn from_term(term: &str) -> Self {
358        let head = term.split('-').next().unwrap_or("");
359        // xterm-<vendor> reassignment when the vendor is a known modern
360        // terminal advertising xterm compatibility.
361        if head == "xterm"
362            && let Some(rest) = term.strip_prefix("xterm-")
363        {
364            let vendor = rest.split('-').next().unwrap_or("");
365            if matches!(vendor, "ghostty" | "kitty" | "rio") {
366                return Self::modern();
367            }
368        }
369        match head {
370            "" | "dumb" => Self::none(),
371            // Modern terminals that advertise the full xterm-era cap
372            // set. Alacritty falls into this bucket too; the detector
373            // uses the shared modern baseline rather than maintaining a
374            // vendor-specific single-flag variant here.
375            "alacritty" | "contour" | "foot" | "ghostty" | "kitty" | "rio" | "st" | "tmux"
376            | "wezterm" => Self::modern(),
377            "xterm" => Self::xterm(),
378            "screen" => Self::screen(),
379            "linux" => Self::linux(),
380            _ => Self::none(),
381        }
382    }
383
384    /// Derive an optimization set from an [`Env`].
385    ///
386    /// Routes `$TERM` through [`Self::from_term`] when it is set, and
387    /// falls back to [`Self::default`] when `$TERM` is unset entirely.
388    /// This keeps callers with no environment information (CI harnesses,
389    /// embedded sinks, tests) on the xterm baseline rather than
390    /// collapsing to [`Self::none`].
391    pub fn from_env(env: &dyn Env) -> Self {
392        match env.get("TERM") {
393            Some(term) => Self::from_term(&term),
394            None => Self::default(),
395        }
396    }
397
398    /// Report whether `env` names a terminal known to implement DECST8C,
399    /// the `ESC [ ? 5 W` sequence that resets tab stops to one every
400    /// eight columns in a single, cursor-safe write.
401    ///
402    /// The allowlist covers Ghostty, kitty, Rio, Alacritty, iTerm2, and
403    /// Windows Terminal. Terminals outside it fall back to the portable
404    /// TBC-then-HTS reset, so a false negative only costs a few extra
405    /// bytes, never correctness. This inspects the environment only and
406    /// never probes the terminal.
407    pub(crate) fn supports_decst8c(env: &dyn Env) -> bool {
408        // Windows Terminal announces itself with a session token rather
409        // than through $TERM.
410        if env.has("WT_SESSION") {
411            return true;
412        }
413        // kitty, Alacritty, and iTerm2 export a session/window id that
414        // survives login shells and multiplexers rewriting $TERM.
415        if env.has("KITTY_WINDOW_ID")
416            || env.has("ALACRITTY_WINDOW_ID")
417            || env.has("ITERM_SESSION_ID")
418        {
419            return true;
420        }
421        // iTerm2 sets $LC_TERMINAL, which also propagates across ssh.
422        if env
423            .get("LC_TERMINAL")
424            .is_some_and(|t| t.eq_ignore_ascii_case("iterm2"))
425        {
426            return true;
427        }
428        // $TERM_PROGRAM outlives some xterm-compatible $TERM rewrites.
429        if let Some(program) = env.get("TERM_PROGRAM")
430            && matches!(
431                program.to_ascii_lowercase().as_str(),
432                "ghostty" | "rio" | "iterm.app"
433            )
434        {
435            return true;
436        }
437        let Some(term) = env.get("TERM") else {
438            return false;
439        };
440        // Match the bare vendor head and the xterm-<vendor> promotion
441        // form, mirroring how `from_term` resolves these terminals.
442        let head = term.split('-').next().unwrap_or("");
443        if matches!(head, "alacritty" | "ghostty" | "kitty" | "rio") {
444            return true;
445        }
446        if let Some(rest) = term.strip_prefix("xterm-") {
447            let vendor = rest.split('-').next().unwrap_or("");
448            return matches!(vendor, "ghostty" | "kitty" | "rio");
449        }
450        false
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    /// The host-dependent flags, as a test-local group: the public API
457    /// deliberately has no name for them, since `Screen` grants `TABS`
458    /// and `BS` but leaves `ONLCR` alone.
459    const LINE_DISCIPLINE: Optimizations = Optimizations::TABS
460        .union(Optimizations::BS)
461        .union(Optimizations::ONLCR);
462
463    use super::*;
464    use crate::terminal::EnvList;
465
466    fn env_with(pairs: &[(&str, &str)]) -> EnvList {
467        EnvList::from_pairs(pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())))
468    }
469
470    #[test]
471    fn supports_decst8c_matches_allowlisted_terms() {
472        for term in [
473            "alacritty",
474            "ghostty",
475            "kitty",
476            "rio",
477            "xterm-kitty",
478            "xterm-ghostty",
479        ] {
480            assert!(
481                Optimizations::supports_decst8c(&env_with(&[("TERM", term)])),
482                "expected DECST8C support for TERM={term}",
483            );
484        }
485    }
486
487    #[test]
488    fn supports_decst8c_rejects_plain_xterm_and_unknown() {
489        for term in [
490            "xterm-256color",
491            "screen-256color",
492            "tmux",
493            "vt100",
494            "dumb",
495            "",
496        ] {
497            assert!(
498                !Optimizations::supports_decst8c(&env_with(&[("TERM", term)])),
499                "did not expect DECST8C support for TERM={term}",
500            );
501        }
502    }
503
504    #[test]
505    fn supports_decst8c_detects_terminals_via_env_tokens() {
506        // Windows Terminal and window-id exporters are recognized even when
507        // $TERM is rewritten to a generic value.
508        assert!(Optimizations::supports_decst8c(&env_with(&[
509            ("TERM", "xterm-256color"),
510            ("WT_SESSION", "abc"),
511        ])));
512        assert!(Optimizations::supports_decst8c(&env_with(&[
513            ("TERM", "xterm-256color"),
514            ("KITTY_WINDOW_ID", "1"),
515        ])));
516        assert!(Optimizations::supports_decst8c(&env_with(&[
517            ("TERM", "xterm-256color"),
518            ("ALACRITTY_WINDOW_ID", "1"),
519        ])));
520        assert!(Optimizations::supports_decst8c(&env_with(&[
521            ("TERM", "screen"),
522            ("TERM_PROGRAM", "ghostty"),
523        ])));
524        // iTerm2 keeps $TERM generic and identifies itself through its own
525        // tokens, including $LC_TERMINAL which survives ssh.
526        assert!(Optimizations::supports_decst8c(&env_with(&[
527            ("TERM", "xterm-256color"),
528            ("TERM_PROGRAM", "iTerm.app"),
529        ])));
530        assert!(Optimizations::supports_decst8c(&env_with(&[
531            ("TERM", "xterm-256color"),
532            ("ITERM_SESSION_ID", "w0t0p0:abc"),
533        ])));
534        assert!(Optimizations::supports_decst8c(&env_with(&[
535            ("TERM", "xterm-256color"),
536            ("LC_TERMINAL", "iTerm2"),
537        ])));
538    }
539
540    #[test]
541    fn default_is_xterm() {
542        assert_eq!(Optimizations::default(), Optimizations::xterm());
543    }
544
545    #[test]
546    fn none_disables_escape_caps_only() {
547        let o = Optimizations::none();
548        assert!(!o.intersects(
549            Optimizations::ECH
550                | Optimizations::REP
551                | Optimizations::ICH
552                | Optimizations::DCH
553                | Optimizations::CSR
554                | Optimizations::SU_SD
555                | Optimizations::IL_DL
556                | Optimizations::BCE
557                | Optimizations::CHA
558                | Optimizations::HPA
559                | Optimizations::VPA
560                | Optimizations::CBT
561                | Optimizations::CHT
562                | Optimizations::ONLCR,
563        ));
564        // The line discipline is not a $TERM question; `Screen::init`
565        // grants TABS and BS once raw mode has settled it instead.
566        assert!(o.is_empty());
567    }
568
569    #[test]
570    fn modern_enables_every_escape_cap() {
571        let o = Optimizations::modern();
572        assert_eq!(o, Optimizations::all().difference(LINE_DISCIPLINE));
573    }
574
575    #[test]
576    fn xterm_drops_hpa_cht_rep() {
577        let o = Optimizations::xterm();
578        assert!(!o.contains(Optimizations::HPA));
579        assert!(!o.contains(Optimizations::CHT));
580        assert!(!o.contains(Optimizations::REP));
581        let expected = Optimizations::modern()
582            .difference(Optimizations::HPA | Optimizations::CHT | Optimizations::REP);
583        assert_eq!(o, expected);
584    }
585
586    #[test]
587    fn vt100_predates_xterm_extensions() {
588        let o = Optimizations::vt100();
589        // No xterm-era absolute positioning or extensions.
590        let missing = Optimizations::CHA
591            | Optimizations::HPA
592            | Optimizations::VPA
593            | Optimizations::ECH
594            | Optimizations::REP
595            | Optimizations::SU_SD
596            | Optimizations::BCE
597            | Optimizations::CBT
598            | Optimizations::CHT
599            | Optimizations::ONLCR;
600        assert!(!o.intersects(missing));
601        // VT100 era margins + VT102 editing pairs.
602        let present =
603            Optimizations::CSR | Optimizations::ICH | Optimizations::DCH | Optimizations::IL_DL;
604        assert!(o.contains(present));
605    }
606
607    #[test]
608    fn linux_matches_console_codes_4() {
609        let o = Optimizations::linux();
610        let present = Optimizations::ECH
611            | Optimizations::ICH
612            | Optimizations::CHA
613            | Optimizations::HPA
614            | Optimizations::VPA;
615        assert_eq!(o, present);
616    }
617
618    #[test]
619    fn screen_matches_infocmp_x1_screen_256color() {
620        let o = Optimizations::screen();
621        let present = Optimizations::ICH
622            | Optimizations::DCH
623            | Optimizations::CSR
624            | Optimizations::SU_SD
625            | Optimizations::IL_DL
626            | Optimizations::HPA
627            | Optimizations::VPA
628            | Optimizations::CBT;
629        assert_eq!(o, present);
630        assert!(!o.contains(Optimizations::BCE));
631        assert!(!o.contains(Optimizations::ECH));
632        assert!(!o.contains(Optimizations::REP));
633        assert!(!o.contains(Optimizations::CHA));
634        assert!(!o.contains(Optimizations::CHT));
635    }
636
637    /// No `$TERM` baseline may carry a line-discipline flag. `\t`,
638    /// `\x08`, and `\n` behave according to the host's output
639    /// processing, which `$TERM` cannot describe; `Screen::init` enables
640    /// `TABS` and `BS` once raw mode has disabled that processing, and
641    /// leaves `ONLCR` to the caller.
642    #[test]
643    fn no_baseline_carries_line_discipline_flags() {
644        let line_discipline = LINE_DISCIPLINE;
645        let baselines = [
646            ("none", Optimizations::none()),
647            ("modern", Optimizations::modern()),
648            ("xterm", Optimizations::xterm()),
649            ("vt100", Optimizations::vt100()),
650            ("linux", Optimizations::linux()),
651            ("screen", Optimizations::screen()),
652            ("default", Optimizations::default()),
653        ];
654        for (name, o) in baselines {
655            assert!(
656                !o.intersects(line_discipline),
657                "{name} baseline must not assume the line discipline: {o:?}"
658            );
659        }
660    }
661
662    /// Every `$TERM` the detector recognizes routes to one of the
663    /// baselines above, so none of them may carry the flags either.
664    #[test]
665    fn no_term_carries_line_discipline_flags() {
666        let line_discipline = LINE_DISCIPLINE;
667        for term in [
668            "alacritty",
669            "contour",
670            "foot",
671            "ghostty",
672            "kitty",
673            "rio",
674            "st",
675            "tmux",
676            "tmux-256color",
677            "wezterm",
678            "xterm",
679            "xterm-256color",
680            "screen",
681            "screen-256color",
682            "linux",
683            "vt100",
684            "dumb",
685            "",
686        ] {
687            let o = Optimizations::from_term(term);
688            assert!(
689                !o.intersects(line_discipline),
690                "{term} must not assume the line discipline: {o:?}"
691            );
692        }
693    }
694
695    #[test]
696    fn from_term_kitty() {
697        let o = Optimizations::from_term("kitty");
698        assert!(o.contains(Optimizations::REP | Optimizations::HPA | Optimizations::VPA));
699    }
700
701    #[test]
702    fn from_term_xterm_excludes_hpa_cht_rep() {
703        let o = Optimizations::from_term("xterm-256color");
704        assert!(!o.contains(Optimizations::HPA));
705        assert!(!o.contains(Optimizations::CHT));
706        assert!(!o.contains(Optimizations::REP));
707        assert!(o.contains(Optimizations::CHA));
708    }
709
710    #[test]
711    fn from_term_xterm_kitty_promotes() {
712        let o = Optimizations::from_term("xterm-kitty");
713        assert!(o.contains(Optimizations::HPA | Optimizations::REP));
714    }
715
716    #[test]
717    fn from_term_xterm_ghostty_promotes() {
718        let o = Optimizations::from_term("xterm-ghostty");
719        assert!(o.contains(Optimizations::HPA | Optimizations::REP));
720    }
721
722    #[test]
723    fn from_term_xterm_rio_promotes() {
724        let o = Optimizations::from_term("xterm-rio");
725        assert!(o.contains(Optimizations::HPA | Optimizations::REP));
726    }
727
728    #[test]
729    fn from_term_linux_console() {
730        assert_eq!(Optimizations::from_term("linux"), Optimizations::linux());
731    }
732
733    #[test]
734    fn from_term_dumb_is_none() {
735        assert_eq!(Optimizations::from_term("dumb"), Optimizations::none());
736    }
737
738    #[test]
739    fn from_term_empty_is_none() {
740        assert_eq!(Optimizations::from_term(""), Optimizations::none());
741    }
742
743    #[test]
744    fn from_term_unknown_falls_back_to_none() {
745        assert_eq!(
746            Optimizations::from_term("madeupterm-256color"),
747            Optimizations::none(),
748        );
749    }
750
751    #[test]
752    fn xterm_term_enables_cha() {
753        let o = Optimizations::from_term("xterm-256color");
754        assert!(o.contains(Optimizations::CHA));
755        assert!(!o.contains(Optimizations::HPA));
756    }
757
758    #[test]
759    fn linux_term_supports_vpa_hpa_not_rep() {
760        let o = Optimizations::from_term("linux");
761        assert!(o.contains(Optimizations::VPA | Optimizations::HPA));
762        assert!(!o.contains(Optimizations::REP));
763    }
764
765    #[test]
766    fn alacritty_term_has_explicit_caps() {
767        let o = Optimizations::from_term("alacritty");
768        assert!(o.contains(Optimizations::CHA | Optimizations::ECH | Optimizations::REP));
769    }
770
771    #[test]
772    fn screen_term_uses_screen_profile() {
773        assert_eq!(
774            Optimizations::from_term("screen-256color"),
775            Optimizations::screen(),
776        );
777    }
778
779    #[test]
780    fn tmux_term_supports_vpa() {
781        assert!(Optimizations::from_term("tmux-256color").contains(Optimizations::VPA));
782    }
783
784    #[test]
785    fn from_term_modern_families_all_enable_rep() {
786        for term in [
787            "contour",
788            "foot",
789            "ghostty",
790            "kitty",
791            "rio",
792            "st",
793            "tmux",
794            "wezterm",
795            "alacritty",
796        ] {
797            let o = Optimizations::from_term(term);
798            assert!(
799                o.contains(Optimizations::REP | Optimizations::HPA),
800                "{term} should enable REP and HPA"
801            );
802        }
803    }
804
805    #[test]
806    fn with_tabs_toggles_tabs() {
807        let on = Optimizations::none().with_tabs(true);
808        let off = Optimizations::none().with_tabs(false);
809        assert!(on.contains(Optimizations::TABS));
810        assert!(!off.contains(Optimizations::TABS));
811    }
812
813    #[test]
814    fn with_bs_toggles_bs() {
815        let on = Optimizations::none().with_bs(true);
816        let off = Optimizations::none().with_bs(false);
817        assert!(on.contains(Optimizations::BS));
818        assert!(!off.contains(Optimizations::BS));
819    }
820
821    #[test]
822    fn with_onlcr_toggles_onlcr() {
823        let on = Optimizations::none().with_onlcr(true);
824        let off = Optimizations::modern().with_onlcr(false);
825        assert!(on.contains(Optimizations::ONLCR));
826        assert!(!off.contains(Optimizations::ONLCR));
827    }
828
829    #[test]
830    fn with_builders_compose() {
831        let o = Optimizations::xterm()
832            .with_rep(true)
833            .with_hpa(true)
834            .with_cha(false);
835        assert!(o.contains(Optimizations::REP | Optimizations::HPA));
836        assert!(!o.contains(Optimizations::CHA));
837    }
838
839    #[test]
840    fn from_env_uses_term_when_set() {
841        let env = EnvList::from_pairs([("TERM", "xterm-kitty")]);
842        assert_eq!(Optimizations::from_env(&env), Optimizations::modern());
843    }
844
845    #[test]
846    fn from_env_dumb_collapses_to_none() {
847        let env = EnvList::from_pairs([("TERM", "dumb")]);
848        assert_eq!(Optimizations::from_env(&env), Optimizations::none());
849    }
850
851    #[test]
852    fn from_env_empty_term_collapses_to_none() {
853        let env = EnvList::from_pairs([("TERM", "")]);
854        assert_eq!(Optimizations::from_env(&env), Optimizations::none());
855    }
856
857    #[test]
858    fn from_env_missing_term_falls_back_to_default() {
859        let env = EnvList::new();
860        assert_eq!(Optimizations::from_env(&env), Optimizations::default());
861    }
862
863    #[test]
864    fn with_builders_are_idempotent() {
865        let a = Optimizations::modern().with_tabs(true).with_tabs(true);
866        let b = Optimizations::modern().with_tabs(true);
867        assert_eq!(a, b);
868        let c = Optimizations::modern().with_rep(false).with_rep(false);
869        let d = Optimizations::modern().with_rep(false);
870        assert_eq!(c, d);
871    }
872}