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}