Skip to content

Commit d5419fe

Browse files
committed
✨ Expose pager heuristic as part of API
1 parent f699311 commit d5419fe

File tree

6 files changed

+79
-27
lines changed

6 files changed

+79
-27
lines changed

crates/pycolorsaurus/src/lib.rs

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,42 +33,49 @@ create_exception!(colorsaurus, ColorsaurusError, PyException);
3333

3434
/// Detects if the terminal is dark or light.
3535
#[pyfunction]
36-
#[pyo3(signature = (*, timeout=None))]
37-
fn color_scheme(timeout: Option<Timeout>) -> PyResult<ColorScheme> {
38-
imp::color_scheme(query_options(timeout))
36+
#[pyo3(signature = (*, timeout=None, require_terminal_on_stdout=false))]
37+
fn color_scheme(
38+
timeout: Option<Timeout>,
39+
require_terminal_on_stdout: bool,
40+
) -> PyResult<ColorScheme> {
41+
imp::color_scheme(query_options(timeout, require_terminal_on_stdout))
3942
.map(ColorScheme::from)
4043
.map_err(to_py_error)
4144
}
4245

4346
/// Queries the terminal for it's foreground and background color.
4447
#[pyfunction]
45-
#[pyo3(signature = (*, timeout=None))]
46-
fn color_palette(timeout: Option<Timeout>) -> PyResult<ColorPalette> {
47-
imp::color_palette(query_options(timeout))
48+
#[pyo3(signature = (*, timeout=None, require_terminal_on_stdout=false))]
49+
fn color_palette(
50+
timeout: Option<Timeout>,
51+
require_terminal_on_stdout: bool,
52+
) -> PyResult<ColorPalette> {
53+
imp::color_palette(query_options(timeout, require_terminal_on_stdout))
4854
.map(ColorPalette)
4955
.map_err(to_py_error)
5056
}
5157

5258
/// Queries the terminal for it's foreground color.
5359
#[pyfunction]
54-
#[pyo3(signature = (*, timeout=None))]
55-
fn foreground_color(timeout: Option<Timeout>) -> PyResult<Color> {
56-
imp::foreground_color(query_options(timeout))
60+
#[pyo3(signature = (*, timeout=None, require_terminal_on_stdout=false))]
61+
fn foreground_color(timeout: Option<Timeout>, require_terminal_on_stdout: bool) -> PyResult<Color> {
62+
imp::foreground_color(query_options(timeout, require_terminal_on_stdout))
5763
.map(Color)
5864
.map_err(to_py_error)
5965
}
6066

6167
/// Queries the terminal for it's background color.
6268
#[pyfunction]
63-
#[pyo3(signature = (*, timeout=None))]
64-
fn background_color(timeout: Option<Timeout>) -> PyResult<Color> {
65-
imp::background_color(query_options(timeout))
69+
#[pyo3(signature = (*, timeout=None, require_terminal_on_stdout=false))]
70+
fn background_color(timeout: Option<Timeout>, require_terminal_on_stdout: bool) -> PyResult<Color> {
71+
imp::background_color(query_options(timeout, require_terminal_on_stdout))
6672
.map(Color)
6773
.map_err(to_py_error)
6874
}
6975

70-
fn query_options(timeout: Option<Timeout>) -> imp::QueryOptions {
71-
let mut options = imp::QueryOptions::default();
76+
fn query_options(timeout: Option<Timeout>, require_terminal_on_stdout: bool) -> imp::QueryOptions {
77+
let mut options =
78+
imp::QueryOptions::default().with_require_terminal_on_stdout(require_terminal_on_stdout);
7279
options.timeout = timeout.map(|t| t.0).unwrap_or(options.timeout);
7380
options
7481
}

crates/terminal-colorsaurus/doc/caveats.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,14 @@ the terminal with another program. This might be the case
55
if you expect your output to be used with a pager e.g. `your_program` | `less`.
66
In that case, a race condition exists because the pager will also set the terminal to raw mode.
77
The `pager` example shows a heuristic to deal with this issue.
8+
9+
If you expect your output to be on stdout then you should enable [`QueryOptions::require_terminal_on_stdout`]:
10+
11+
```rust,no_run
12+
use terminal_colorsaurus::{color_palette, QueryOptions, color_scheme};
13+
14+
let options = QueryOptions::default().with_require_terminal_on_stdout(true);
15+
let theme = color_scheme(options).unwrap();
16+
```
17+
18+
See the `pager` example for more details.

crates/terminal-colorsaurus/examples/pager.rs

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,11 @@
1919
//! 3. `cargo run --example pager | cat`—should not print the color scheme. This is a false negatives.
2020
//! 4. `cargo run --example pager 2>&1 >/dev/tty | less`—should print the color scheme (or error). This is a false positive.
2121
22-
use std::io::{stdout, IsTerminal as _};
2322
use terminal_colorsaurus::{color_palette, Error, QueryOptions};
2423

2524
fn main() -> Result<(), display::DisplayAsDebug<Error>> {
26-
if stdout().is_terminal() {
27-
eprintln!(
28-
"Here's the color scheme: {:#?}",
29-
color_palette(QueryOptions::default())?
30-
);
31-
} else {
32-
eprintln!("No color scheme for you today :/");
33-
}
34-
25+
let options = QueryOptions::default().with_require_terminal_on_stdout(true);
26+
eprintln!("Here's the color palette: {:#?}", color_palette(options)?);
3527
Ok(())
3628
}
3729

crates/terminal-colorsaurus/src/error.rs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
use crate::fmt::CaretNotation;
2-
use core::fmt;
32
use std::time::Duration;
4-
use std::{error, io};
3+
use std::{error, fmt, io};
54

65
/// An error returned by this library.
76
#[derive(Debug)]
@@ -15,6 +14,10 @@ pub enum Error {
1514
/// either the terminal does not support querying for colors \
1615
/// or the terminal has a lot of latency (e.g. when connected via SSH).
1716
Timeout(Duration),
17+
/// Stdout is not connected to a terminal, but [`QueryOptions::require_terminal_on_stdout`] was set.
18+
///
19+
/// [`QueryOptions::require_terminal_on_stdout`]: `crate::QueryOptions::require_terminal_on_stdout`
20+
NotATerminal(NotATerminalError),
1821
/// The terminal does not support querying for the foreground or background color.
1922
UnsupportedTerminal,
2023
}
@@ -23,6 +26,7 @@ impl error::Error for Error {
2326
fn source(&self) -> Option<&(dyn error::Error + 'static)> {
2427
match self {
2528
Error::Io(source) => Some(source),
29+
Error::NotATerminal(source) => Some(source),
2630
_ => None,
2731
}
2832
}
@@ -42,6 +46,7 @@ impl fmt::Display for Error {
4246
Error::Timeout(timeout) => {
4347
write!(f, "operation did not complete within {timeout:?}")
4448
}
49+
Error::NotATerminal(e) => fmt::Display::fmt(e, f),
4550
Error::UnsupportedTerminal {} => {
4651
write!(f, "the terminal does not support querying for its colors")
4752
}
@@ -54,3 +59,15 @@ impl From<io::Error> for Error {
5459
Error::Io(source)
5560
}
5661
}
62+
63+
#[derive(Debug)]
64+
#[non_exhaustive]
65+
pub struct NotATerminalError;
66+
67+
impl error::Error for NotATerminalError {}
68+
69+
impl fmt::Display for NotATerminalError {
70+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71+
write!(f, "stdout is not connected to a terminal")
72+
}
73+
}

crates/terminal-colorsaurus/src/lib.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,12 +143,26 @@ pub struct QueryOptions {
143143
///
144144
/// See [Feature Detection](`feature_detection`) for details on how this works.
145145
pub timeout: std::time::Duration,
146+
147+
/// Only query the terminal for its colors if stdout is a terminal.
148+
///
149+
/// This is used to heuristically avoid race-conditions with pagers.
150+
pub require_terminal_on_stdout: bool,
151+
}
152+
153+
impl QueryOptions {
154+
/// Sets [`Self::require_terminal_on_stdout`].
155+
pub fn with_require_terminal_on_stdout(mut self, require_terminal_on_stdout: bool) -> Self {
156+
self.require_terminal_on_stdout = require_terminal_on_stdout;
157+
self
158+
}
146159
}
147160

148161
impl Default for QueryOptions {
149162
fn default() -> Self {
150163
Self {
151164
timeout: std::time::Duration::from_secs(1),
165+
require_terminal_on_stdout: false,
152166
}
153167
}
154168
}

crates/terminal-colorsaurus/src/xterm.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
use crate::error::NotATerminalError;
12
use crate::io::{read_until2, TermReader};
23
use crate::quirks::{terminal_quirks_from_env, TerminalQuirks};
34
use crate::{Color, ColorPalette, Error, QueryOptions, Result};
4-
use std::io::{self, BufRead, BufReader, Write as _};
5+
use std::io::{self, BufRead, BufReader, IsTerminal, Write as _};
56
use std::time::Duration;
67
use terminal_trx::{terminal, RawModeGuard};
78

@@ -101,6 +102,8 @@ fn query<T>(
101102
write_query: impl FnOnce(&mut dyn io::Write) -> io::Result<()>,
102103
read_response: impl FnOnce(&mut Reader<'_>) -> Result<T>,
103104
) -> Result<T> {
105+
ensure_is_terminal(options.require_terminal_on_stdout)?;
106+
104107
if quirks.is_known_unsupported() {
105108
return Err(Error::UnsupportedTerminal);
106109
}
@@ -124,6 +127,14 @@ fn query<T>(
124127
Ok(response)
125128
}
126129

130+
fn ensure_is_terminal(require_terminal_on_stdout: bool) -> Result<()> {
131+
if require_terminal_on_stdout && !io::stdout().is_terminal() {
132+
Err(Error::NotATerminal(NotATerminalError))
133+
} else {
134+
Ok(())
135+
}
136+
}
137+
127138
fn read_color_response(r: &mut Reader<'_>) -> Result<Vec<u8>> {
128139
let mut buf = Vec::new();
129140
r.read_until(ESC, &mut buf)?; // Both responses start with ESC

0 commit comments

Comments
 (0)