termbg_with_async_stdin/
lib.rs

1use crossterm::terminal;
2use std::env;
3use std::io::IsTerminal;
4use std::io::{self, Write};
5use std::time::{Duration, Instant};
6use thiserror::Error;
7use tokio::runtime::Runtime;
8#[cfg(target_os = "windows")]
9use winapi::um::wincon;
10
11mod stdin;
12
13/// Terminal
14#[derive(Copy, Clone, Debug, PartialEq, Eq)]
15pub enum Terminal {
16    Screen,
17    Tmux,
18    XtermCompatible,
19    Windows,
20    Emacs,
21}
22
23/// 16bit RGB color
24#[derive(Copy, Clone, Debug, PartialEq, Eq)]
25pub struct Rgb {
26    pub r: u16,
27    pub g: u16,
28    pub b: u16,
29}
30
31/// Background theme
32#[derive(Copy, Clone, Debug, PartialEq, Eq)]
33pub enum Theme {
34    Light,
35    Dark,
36}
37
38/// Error
39#[derive(Error, Debug)]
40pub enum Error {
41    #[error("io error")]
42    Io {
43        #[from]
44        source: io::Error,
45    },
46    #[error("parse error")]
47    Parse(String),
48    #[error("unsupported")]
49    Unsupported,
50    #[error("timeout")]
51    Timeout,
52}
53
54/// get detected termnial
55#[cfg(not(target_os = "windows"))]
56pub fn terminal() -> Terminal {
57    if env::var("INSIDE_EMACS").is_ok() {
58        return Terminal::Emacs;
59    }
60
61    if env::var("TMUX").is_ok() || env::var("TERM").is_ok_and(|x| x.starts_with("tmux-")) {
62        Terminal::Tmux
63    } else {
64        let is_screen = if let Ok(term) = env::var("TERM") {
65            term.starts_with("screen")
66        } else {
67            false
68        };
69        if is_screen {
70            Terminal::Screen
71        } else {
72            Terminal::XtermCompatible
73        }
74    }
75}
76
77/// get detected termnial
78#[cfg(target_os = "windows")]
79pub fn terminal() -> Terminal {
80    if let Ok(term_program) = env::var("TERM_PROGRAM") {
81        if term_program == "vscode" {
82            return Terminal::XtermCompatible;
83        }
84    }
85
86    if env::var("INSIDE_EMACS").is_ok() {
87        return Terminal::Emacs;
88    }
89
90    // Windows Terminal is Xterm-compatible
91    // https://github.com/microsoft/terminal/issues/3718
92    if env::var("WT_SESSION").is_ok() {
93        Terminal::XtermCompatible
94    } else {
95        Terminal::Windows
96    }
97}
98
99/// get background color by `RGB`
100#[cfg(not(target_os = "windows"))]
101pub fn rgb(timeout: Duration) -> Result<Rgb, Error> {
102    let term = terminal();
103    let rgb = match term {
104        Terminal::Emacs => Err(Error::Unsupported),
105        _ => from_xterm(term, timeout),
106    };
107    let fallback = from_env_colorfgbg();
108    if rgb.is_ok() {
109        rgb
110    } else if fallback.is_ok() {
111        fallback
112    } else {
113        rgb
114    }
115}
116
117/// get background color by `RGB`
118#[cfg(target_os = "windows")]
119pub fn rgb(timeout: Duration) -> Result<Rgb, Error> {
120    let term = terminal();
121    let rgb = match term {
122        Terminal::Emacs => Err(Error::Unsupported),
123        Terminal::XtermCompatible => from_xterm(term, timeout),
124        _ => from_winapi(),
125    };
126    let fallback = from_env_colorfgbg();
127    if rgb.is_ok() {
128        rgb
129    } else if fallback.is_ok() {
130        fallback
131    } else {
132        rgb
133    }
134}
135
136/// get background color by `RGB`
137#[cfg(not(target_os = "windows"))]
138pub fn latency(timeout: Duration) -> Result<Duration, Error> {
139    let term = terminal();
140    match term {
141        Terminal::Emacs => Ok(Duration::from_millis(0)),
142        _ => xterm_latency(timeout),
143    }
144}
145
146/// get background color by `RGB`
147#[cfg(target_os = "windows")]
148pub fn latency(timeout: Duration) -> Result<Duration, Error> {
149    let term = terminal();
150    match term {
151        Terminal::Emacs => Ok(Duration::from_millis(0)),
152        Terminal::XtermCompatible => xterm_latency(timeout),
153        _ => Ok(Duration::from_millis(0)),
154    }
155}
156
157/// get background color by `Theme`
158pub fn theme(timeout: Duration) -> Result<Theme, Error> {
159    let rgb = rgb(timeout)?;
160
161    // ITU-R BT.601
162    let y = rgb.r as f64 * 0.299 + rgb.g as f64 * 0.587 + rgb.b as f64 * 0.114;
163
164    if y > 32768.0 {
165        Ok(Theme::Light)
166    } else {
167        Ok(Theme::Dark)
168    }
169}
170
171fn from_xterm(term: Terminal, timeout: Duration) -> Result<Rgb, Error> {
172    if !std::io::stdin().is_terminal()
173        || !std::io::stdout().is_terminal()
174        || !std::io::stderr().is_terminal()
175    {
176        // Not a terminal, so don't try to read the current background color.
177        return Err(Error::Unsupported);
178    }
179
180    // Query by XTerm control sequence
181    let query = if term == Terminal::Tmux {
182        "\x1bPtmux;\x1b\x1b]11;?\x07\x1b\\\x03"
183    } else if term == Terminal::Screen {
184        "\x1bP\x1b]11;?\x07\x1b\\\x03"
185    } else {
186        "\x1b]11;?\x1b\\"
187    };
188
189    let mut stderr = io::stderr();
190    terminal::enable_raw_mode()?;
191    write!(stderr, "{}", query)?;
192    stderr.flush()?;
193
194    let rt = Runtime::new()?;
195    //let rt = Builder::new_current_thread().enable_all().build().unwrap();
196    let buffer: Result<_, Error> = rt.block_on(async {
197        use tokio::io::AsyncReadExt;
198        use tokio::time;
199        let mut buffer = Vec::new();
200        let mut stdin = stdin::stdin()?;
201        let mut buf = [0; 1];
202        let mut start = false;
203        loop {
204            if let Err(_) = time::timeout(timeout, stdin.read_exact(&mut buf)).await {
205                return Err(Error::Timeout);
206            }
207            // response terminated by BEL(0x7)
208            if start && (buf[0] == 0x7) {
209                break;
210            }
211            // response terminated by ST(0x1b 0x5c)
212            if start && (buf[0] == 0x1b) {
213                // consume last 0x5c
214                if let Err(_) = time::timeout(timeout, stdin.read_exact(&mut buf)).await {
215                    return Err(Error::Timeout);
216                }
217                debug_assert_eq!(buf[0], 0x5c);
218                break;
219            }
220            if start {
221                buffer.push(buf[0]);
222            }
223            if buf[0] == b':' {
224                start = true;
225            }
226        }
227        Ok(buffer)
228    });
229
230    terminal::disable_raw_mode()?;
231
232    // Should return by error after disable_raw_mode
233    let buffer = buffer?;
234
235    let s = String::from_utf8_lossy(&buffer);
236    let (r, g, b) = decode_x11_color(&*s)?;
237    Ok(Rgb { r, g, b })
238}
239
240fn from_env_colorfgbg() -> Result<Rgb, Error> {
241    let var = env::var("COLORFGBG").map_err(|_| Error::Unsupported)?;
242    let fgbg: Vec<_> = var.split(";").collect();
243    let bg = fgbg.get(1).ok_or(Error::Unsupported)?;
244    let bg = u8::from_str_radix(bg, 10).map_err(|_| Error::Parse(String::from(var)))?;
245
246    // rxvt default color table
247    let (r, g, b) = match bg {
248        // black
249        0 => (0, 0, 0),
250        // red
251        1 => (205, 0, 0),
252        // green
253        2 => (0, 205, 0),
254        // yellow
255        3 => (205, 205, 0),
256        // blue
257        4 => (0, 0, 238),
258        // magenta
259        5 => (205, 0, 205),
260
261        // cyan
262        6 => (0, 205, 205),
263        // white
264        7 => (229, 229, 229),
265        // bright black
266        8 => (127, 127, 127),
267        // bright red
268        9 => (255, 0, 0),
269        // bright green
270        10 => (0, 255, 0),
271        // bright yellow
272        11 => (255, 255, 0),
273        // bright blue
274        12 => (92, 92, 255),
275        // bright magenta
276        13 => (255, 0, 255),
277        // bright cyan
278        14 => (0, 255, 255),
279
280        // bright white
281        15 => (255, 255, 255),
282        _ => (0, 0, 0),
283    };
284
285    Ok(Rgb {
286        r: r * 256,
287        g: g * 256,
288        b: b * 256,
289    })
290}
291
292fn xterm_latency(timeout: Duration) -> Result<Duration, Error> {
293    // Query by XTerm control sequence
294    let query = "\x1b[5n";
295
296    let mut stderr = io::stderr();
297    terminal::enable_raw_mode()?;
298    write!(stderr, "{}", query)?;
299    stderr.flush()?;
300
301    let start = Instant::now();
302
303    let rt = Runtime::new()?;
304    //let rt = Builder::new_current_thread().enable_all().build().unwrap();
305    let ret: Result<_, Error> = rt.block_on(async {
306        use tokio::io::AsyncReadExt;
307        use tokio::time;
308        let mut stdin = stdin::stdin()?;
309        let mut buf = [0; 1];
310        loop {
311            if let Err(_) = time::timeout(timeout, stdin.read_exact(&mut buf)).await {
312                return Err(Error::Timeout);
313            }
314            // response terminated by 'n'
315            if buf[0] == b'n' {
316                break;
317            }
318        }
319        Ok(())
320    });
321
322    let end = start.elapsed();
323
324    terminal::disable_raw_mode()?;
325
326    let _ = ret?;
327
328    Ok(end)
329}
330
331fn decode_x11_color(s: &str) -> Result<(u16, u16, u16), Error> {
332    fn decode_hex(s: &str) -> Result<u16, Error> {
333        let len = s.len() as u32;
334        let mut ret = u16::from_str_radix(s, 16).map_err(|_| Error::Parse(String::from(s)))?;
335        ret = ret << ((4 - len) * 4);
336        Ok(ret)
337    }
338
339    let rgb: Vec<_> = s.split("/").collect();
340
341    let r = rgb.get(0).ok_or_else(|| Error::Parse(String::from(s)))?;
342    let g = rgb.get(1).ok_or_else(|| Error::Parse(String::from(s)))?;
343    let b = rgb.get(2).ok_or_else(|| Error::Parse(String::from(s)))?;
344    let r = decode_hex(r)?;
345    let g = decode_hex(g)?;
346    let b = decode_hex(b)?;
347
348    Ok((r, g, b))
349}
350
351#[cfg(target_os = "windows")]
352fn from_winapi() -> Result<Rgb, Error> {
353    let info = unsafe {
354        let handle = winapi::um::processenv::GetStdHandle(winapi::um::winbase::STD_OUTPUT_HANDLE);
355        let mut info: wincon::CONSOLE_SCREEN_BUFFER_INFO = Default::default();
356        wincon::GetConsoleScreenBufferInfo(handle, &mut info);
357        info
358    };
359
360    let r = (wincon::BACKGROUND_RED & info.wAttributes) != 0;
361    let g = (wincon::BACKGROUND_GREEN & info.wAttributes) != 0;
362    let b = (wincon::BACKGROUND_BLUE & info.wAttributes) != 0;
363    let i = (wincon::BACKGROUND_INTENSITY & info.wAttributes) != 0;
364
365    let r: u8 = r as u8;
366    let g: u8 = g as u8;
367    let b: u8 = b as u8;
368    let i: u8 = i as u8;
369
370    let (r, g, b) = match (r, g, b, i) {
371        (0, 0, 0, 0) => (0, 0, 0),
372        (1, 0, 0, 0) => (128, 0, 0),
373        (0, 1, 0, 0) => (0, 128, 0),
374        (1, 1, 0, 0) => (128, 128, 0),
375        (0, 0, 1, 0) => (0, 0, 128),
376        (1, 0, 1, 0) => (128, 0, 128),
377        (0, 1, 1, 0) => (0, 128, 128),
378        (1, 1, 1, 0) => (192, 192, 192),
379        (0, 0, 0, 1) => (128, 128, 128),
380        (1, 0, 0, 1) => (255, 0, 0),
381        (0, 1, 0, 1) => (0, 255, 0),
382        (1, 1, 0, 1) => (255, 255, 0),
383        (0, 0, 1, 1) => (0, 0, 255),
384        (1, 0, 1, 1) => (255, 0, 255),
385        (0, 1, 1, 1) => (0, 255, 255),
386        (1, 1, 1, 1) => (255, 255, 255),
387        _ => unreachable!(),
388    };
389
390    Ok(Rgb {
391        r: r * 256,
392        g: g * 256,
393        b: b * 256,
394    })
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    #[test]
402    fn test_decode_x11_color() {
403        let s = "0000/0000/0000";
404        assert_eq!((0, 0, 0), decode_x11_color(s).unwrap());
405
406        let s = "1111/2222/3333";
407        assert_eq!((0x1111, 0x2222, 0x3333), decode_x11_color(s).unwrap());
408
409        let s = "111/222/333";
410        assert_eq!((0x1110, 0x2220, 0x3330), decode_x11_color(s).unwrap());
411
412        let s = "11/22/33";
413        assert_eq!((0x1100, 0x2200, 0x3300), decode_x11_color(s).unwrap());
414
415        let s = "1/2/3";
416        assert_eq!((0x1000, 0x2000, 0x3000), decode_x11_color(s).unwrap());
417    }
418}