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