diff --git a/crates/terminal-colorsaurus/doc/caveats.md b/crates/terminal-colorsaurus/doc/caveats.md index 8db06f1..f84f372 100644 --- a/crates/terminal-colorsaurus/doc/caveats.md +++ b/crates/terminal-colorsaurus/doc/caveats.md @@ -5,3 +5,15 @@ the terminal with another program. This might be the case if you expect your output to be used with a pager e.g. `your_program` | `less`. In that case, a race condition exists because the pager will also set the terminal to raw mode. The `pager` example shows a heuristic to deal with this issue. + +If you expect your output to be on stdout then you should set [`QueryOptions::require_terminal_on`] +accordingly: + +```rust,no_run +use terminal_colorsaurus::{color_palette, QueryOptions, Stdio, color_scheme}; + +let options = QueryOptions::default().with_require_terminal_on(Stdio::STDOUT); +let theme = color_scheme(options).unwrap(); +``` + +See the `pager` example for more details. diff --git a/crates/terminal-colorsaurus/examples/pager.rs b/crates/terminal-colorsaurus/examples/pager.rs index 2a4b2bf..c517e19 100644 --- a/crates/terminal-colorsaurus/examples/pager.rs +++ b/crates/terminal-colorsaurus/examples/pager.rs @@ -19,19 +19,11 @@ //! 3. `cargo run --example pager | cat`—should not print the color scheme. This is a false negatives. //! 4. `cargo run --example pager 2>&1 >/dev/tty | less`—should print the color scheme (or error). This is a false positive. -use std::io::{stdout, IsTerminal as _}; -use terminal_colorsaurus::{color_palette, Error, QueryOptions}; +use terminal_colorsaurus::{color_palette, Error, QueryOptions, Stdio}; fn main() -> Result<(), display::DisplayAsDebug> { - if stdout().is_terminal() { - eprintln!( - "Here's the color scheme: {:#?}", - color_palette(QueryOptions::default())? - ); - } else { - eprintln!("No color scheme for you today :/"); - } - + let options = QueryOptions::default().with_require_terminal_on(Stdio::STDOUT); + eprintln!("Here's the color palette: {:#?}", color_palette(options)?); Ok(()) } diff --git a/crates/terminal-colorsaurus/src/error.rs b/crates/terminal-colorsaurus/src/error.rs index f074604..bf8d116 100644 --- a/crates/terminal-colorsaurus/src/error.rs +++ b/crates/terminal-colorsaurus/src/error.rs @@ -1,4 +1,5 @@ use crate::fmt::CaretNotation; +use crate::Stdio; use core::fmt; use std::time::Duration; use std::{error, io}; @@ -15,6 +16,9 @@ pub enum Error { /// either the terminal does not support querying for colors \ /// or the terminal has a lot of latency (e.g. when connected via SSH). Timeout(Duration), + /// The query options expected a terminal on the given standard I/O stream, + /// but it was not connected to a terminal. + NotATerminal(NotATerminalError), /// The terminal does not support querying for the foreground or background color. UnsupportedTerminal, } @@ -23,6 +27,7 @@ impl error::Error for Error { fn source(&self) -> Option<&(dyn error::Error + 'static)> { match self { Error::Io(source) => Some(source), + Error::NotATerminal(source) => Some(source), _ => None, } } @@ -42,6 +47,7 @@ impl fmt::Display for Error { Error::Timeout(timeout) => { write!(f, "operation did not complete within {timeout:?}") } + Error::NotATerminal(e) => fmt::Display::fmt(e, f), Error::UnsupportedTerminal {} => { write!(f, "the terminal does not support querying for its colors") } @@ -54,3 +60,26 @@ impl From for Error { Error::Io(source) } } + +#[derive(Debug)] +#[non_exhaustive] +pub struct NotATerminalError(pub(crate) Stdio); + +impl error::Error for NotATerminalError {} + +impl fmt::Display for NotATerminalError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.0 { + s if s == Stdio::STDIN => { + write!(f, "stdin is not connected to a terminal") + } + s if s == Stdio::STDOUT => { + write!(f, "stdout is not connected to a terminal") + } + s if s == Stdio::STDERR => { + write!(f, "stderr is not connected to a terminal") + } + _ => unreachable!("the first failing standard i/o stream generates an error"), + } + } +} diff --git a/crates/terminal-colorsaurus/src/lib.rs b/crates/terminal-colorsaurus/src/lib.rs index 231b02c..2be30d4 100644 --- a/crates/terminal-colorsaurus/src/lib.rs +++ b/crates/terminal-colorsaurus/src/lib.rs @@ -131,6 +131,7 @@ impl ColorPalette { /// Result used by this library. pub type Result = std::result::Result; pub use error::Error; +use std::ops::BitOr; /// Options to be used with [`foreground_color`] and [`background_color`]. /// You should almost always use the unchanged [`QueryOptions::default`] value. @@ -147,16 +148,60 @@ pub struct QueryOptions { /// /// See [Feature Detection](`feature_detection`) for details on how this works. pub timeout: Duration, + + /// Only query the terminal for its colors if all of the + /// provided standard I/O streams are a terminal. + /// + /// This is used to heuristically avoid race-conditions with pagers. + pub require_terminal_on: Stdio, +} + +impl QueryOptions { + /// Sets [`Self::require_terminal_on`]. + pub fn with_require_terminal_on(mut self, require_terminal_on: Stdio) -> Self { + self.require_terminal_on = require_terminal_on; + self + } } impl Default for QueryOptions { fn default() -> Self { Self { timeout: Duration::from_secs(1), + require_terminal_on: Stdio::default(), } } } +/// A bitset representing zero or more standard I/O streams. +/// +/// Two [`Stdio`]s can be `or`ed together to represent a combination. +/// For example, `Stdio::STDOUT | Stdio::STDERR` represents a set containing both stdout and stderr. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct Stdio(u8); + +impl Stdio { + /// The standard input stream. See [`std::io::stdin`]. + pub const STDIN: Self = Self(1 << 0); + /// The standard output stream. See [`std::io::stdout`]. + pub const STDOUT: Self = Self(1 << 1); + /// The standard error stream. See [`std::io::stderr`]. + pub const STDERR: Self = Self(1 << 2); + + #[cfg(all(any(unix, windows), not(terminal_colorsaurus_test_unsupported)))] + pub(crate) fn is(self, other: Self) -> bool { + self.0 & other.0 == other.0 + } +} + +impl BitOr for Stdio { + type Output = Stdio; + + fn bitor(self, rhs: Self) -> Self::Output { + Self(self.0 | rhs.0) + } +} + /// Detects if the terminal is dark or light. #[doc = include_str!("../doc/caveats.md")] #[doc(alias = "theme")] diff --git a/crates/terminal-colorsaurus/src/xterm.rs b/crates/terminal-colorsaurus/src/xterm.rs index 4206ef1..de251be 100644 --- a/crates/terminal-colorsaurus/src/xterm.rs +++ b/crates/terminal-colorsaurus/src/xterm.rs @@ -1,8 +1,9 @@ +use crate::error::NotATerminalError; use crate::io::{read_until2, TermReader}; use crate::quirks::{terminal_quirks_from_env, TerminalQuirks}; use crate::xparsecolor::xparsecolor; -use crate::{Color, ColorPalette, Error, QueryOptions, Result}; -use std::io::{self, BufRead, BufReader, Write as _}; +use crate::{Color, ColorPalette, Error, QueryOptions, Result, Stdio}; +use std::io::{self, BufRead, BufReader, IsTerminal, Write as _}; use std::time::Duration; use terminal_trx::{terminal, RawModeGuard}; @@ -92,6 +93,8 @@ fn query( write_query: impl FnOnce(&mut dyn io::Write) -> io::Result<()>, read_response: impl FnOnce(&mut Reader<'_>) -> Result, ) -> Result { + ensure_is_terminal(options.require_terminal_on)?; + if quirks.is_known_unsupported() { return Err(Error::UnsupportedTerminal); } @@ -115,6 +118,18 @@ fn query( Ok(response) } +fn ensure_is_terminal(stdio: Stdio) -> Result<()> { + if stdio.is(Stdio::STDIN) && !io::stdin().is_terminal() { + Err(Error::NotATerminal(NotATerminalError(Stdio::STDIN))) + } else if stdio.is(Stdio::STDOUT) && !io::stdout().is_terminal() { + Err(Error::NotATerminal(NotATerminalError(Stdio::STDOUT))) + } else if stdio.is(Stdio::STDERR) && !io::stderr().is_terminal() { + Err(Error::NotATerminal(NotATerminalError(Stdio::STDERR))) + } else { + Ok(()) + } +} + fn read_color_response(r: &mut Reader<'_>) -> Result> { let mut buf = Vec::new(); r.read_until(ESC, &mut buf)?; // Both responses start with ESC