Skip to content

Commit

Permalink
✨ Expose pager heuristic as part of API
Browse files Browse the repository at this point in the history
  • Loading branch information
bash committed Dec 17, 2024
1 parent 1056a92 commit 81a918d
Show file tree
Hide file tree
Showing 5 changed files with 106 additions and 13 deletions.
12 changes: 12 additions & 0 deletions crates/terminal-colorsaurus/doc/caveats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
14 changes: 3 additions & 11 deletions crates/terminal-colorsaurus/examples/pager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Error>> {
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(())
}

Expand Down
29 changes: 29 additions & 0 deletions crates/terminal-colorsaurus/src/error.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::fmt::CaretNotation;
use crate::Stdio;
use core::fmt;
use std::time::Duration;
use std::{error, io};
Expand All @@ -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,
}
Expand All @@ -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,
}
}
Expand All @@ -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")
}
Expand All @@ -54,3 +60,26 @@ impl From<io::Error> 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"),
}
}
}
45 changes: 45 additions & 0 deletions crates/terminal-colorsaurus/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ impl ColorPalette {
/// Result used by this library.
pub type Result<T> = std::result::Result<T, Error>;
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.
Expand All @@ -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")]
Expand Down
19 changes: 17 additions & 2 deletions crates/terminal-colorsaurus/src/xterm.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand Down Expand Up @@ -92,6 +93,8 @@ fn query<T>(
write_query: impl FnOnce(&mut dyn io::Write) -> io::Result<()>,
read_response: impl FnOnce(&mut Reader<'_>) -> Result<T>,
) -> Result<T> {
ensure_is_terminal(options.require_terminal_on)?;

if quirks.is_known_unsupported() {
return Err(Error::UnsupportedTerminal);
}
Expand All @@ -115,6 +118,18 @@ fn query<T>(
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<Vec<u8>> {
let mut buf = Vec::new();
r.read_until(ESC, &mut buf)?; // Both responses start with ESC
Expand Down

0 comments on commit 81a918d

Please sign in to comment.