omp_tui/tty.rs
1//! Terminal device resolution honoring the `OMP_TTY` override.
2//!
3//! The UI talks to the controlling terminal directly (`/dev/tty` on Unix), so
4//! a harness that only owns the process's pipes cannot observe or drive it.
5//! Setting `OMP_TTY` to an alternate terminal device — typically a pty slave
6//! whose master the harness holds — reroutes every terminal open (input,
7//! output via [`TtyOut`], capability probes) and the terminal identity to
8//! that device, delivering the complete byte stream a real terminal would
9//! see on the master side.
10//!
11//! Limitations under an override: `SIGWINCH` is only delivered for the
12//! controlling terminal, so live resizes will not propagate unless the
13//! terminal supports in-band resize; set the window size up front with
14//! `TIOCSWINSZ` on the master.
15
16use std::{
17 fs::{File, OpenOptions},
18 io::{self, IoSlice, Write},
19 path::PathBuf,
20 sync::LazyLock,
21};
22
23/// Environment variable naming an alternate terminal device.
24pub const TTY_OVERRIDE: &str = "OMP_TTY";
25
26/// The overriding device path, when [`TTY_OVERRIDE`] is set and non-empty.
27pub fn override_path() -> Option<PathBuf> {
28 std::env::var_os(TTY_OVERRIDE)
29 .filter(|value| !value.is_empty())
30 .map(PathBuf::from)
31}
32
33/// Whether an override device is configured. Cached: the environment is
34/// read once, matching the process-lifetime scope of the override.
35pub fn overridden() -> bool {
36 static OVERRIDDEN: LazyLock<bool> = LazyLock::new(|| override_path().is_some());
37 *OVERRIDDEN
38}
39
40/// Opens the terminal device with the given options, honoring [`TTY_OVERRIDE`].
41#[cfg(unix)]
42pub fn open(options: &OpenOptions) -> io::Result<File> {
43 match override_path() {
44 Some(path) => options.open(path),
45 None => options.open("/dev/tty"),
46 }
47}
48
49/// Terminal output sink: stdout normally, the `OMP_TTY` device when set.
50///
51/// [`crate::App`] and full-screen frontends render through this so that an
52/// `OMP_TTY` override captures rendered frames alongside the control
53/// sequences, not just the lifecycle bytes.
54pub struct TtyOut(Sink);
55
56enum Sink {
57 Stdout(io::Stdout),
58 Device(File),
59}
60
61impl TtyOut {
62 /// Opens the terminal output sink.
63 ///
64 /// # Errors
65 /// Fails when `OMP_TTY` names a path that cannot be opened for writing;
66 /// a misconfigured override is reported rather than silently ignored.
67 pub fn new() -> io::Result<Self> {
68 match override_path() {
69 Some(path) => Ok(Self(Sink::Device(OpenOptions::new().write(true).open(path)?))),
70 None => Ok(Self(Sink::Stdout(io::stdout()))),
71 }
72 }
73}
74
75impl Write for TtyOut {
76 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
77 match &mut self.0 {
78 Sink::Stdout(out) => out.write(buf),
79 Sink::Device(out) => out.write(buf),
80 }
81 }
82
83 fn write_vectored(&mut self, bufs: &[IoSlice<'_>]) -> io::Result<usize> {
84 match &mut self.0 {
85 Sink::Stdout(out) => out.write_vectored(bufs),
86 Sink::Device(out) => out.write_vectored(bufs),
87 }
88 }
89
90 fn flush(&mut self) -> io::Result<()> {
91 match &mut self.0 {
92 Sink::Stdout(out) => out.flush(),
93 Sink::Device(out) => out.flush(),
94 }
95 }
96}