Skip to content

Commit

Permalink
- add support for lighweight terminal I/O on Unix only
Browse files Browse the repository at this point in the history
- add small command line utility for experimenting with terminal I/O
- add support for nicely rendering terminal input
  • Loading branch information
apparebit committed Oct 28, 2024
1 parent 9bbebf7 commit cfa8d6a
Show file tree
Hide file tree
Showing 7 changed files with 584 additions and 75 deletions.
25 changes: 13 additions & 12 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 8 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ rust-version = "1.79"


[dependencies]
pyo3 = { version = "0.22.0", features = ["extension-module", "abi3", "abi3-py311"], optional = true }
pyo3 = { version = "0.22.5", features = ["extension-module", "abi3", "abi3-py311"], optional = true }

[target.'cfg(unix)'.dependencies]
libc = "0.2.161"

[features]
default = ["f64"]
Expand All @@ -28,6 +30,11 @@ pyffi = ["dep:pyo3"]
[lib]
name = "prettypretty"
crate-type = ["lib", "cdylib"]
path = "src/lib.rs"

[[bin]]
name = "prettyio"
path = "src/prettyio.rs"


[package.metadata.docs.rs]
Expand Down
66 changes: 66 additions & 0 deletions src/prettyio.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
#![cfg(target_family = "unix")]

use prettypretty::termio::{render, Terminal, TERMINAL_TIMEOUT};
use prettypretty::trans::ThemeEntry;
use std::io::{Read, Result, Write};

pub fn main() -> Result<()> {
let terminal = Terminal::open()?.cbreak_mode(TERMINAL_TIMEOUT)?;
let mut reader = terminal.reader();
let mut writer = terminal.writer();
let mut entries = ThemeEntry::all();

write!(
writer,
"press ‹t› to query rotating theme color, ‹q› to quit\r\n\r\n"
)?;

let mut iterations = 0;
loop {
iterations += 1;
if 1000 <= iterations {
write!(writer, "✋")?;
break;
}

let mut buffer = [0; 32];
let count = reader.read(&mut buffer)?;
if count == 0 {
write!(writer, "◦")?;
continue;
}

write!(writer, "〈")?;
let mut terminate = false;
let mut query = None;

for b in buffer.iter().take(count) {
render(*b, &mut writer)?;

if *b == b'q' {
terminate = true;
} else if *b == b't' {
let mut entry = entries.next();
if entry.is_none() {
entries = ThemeEntry::all();
entry = entries.next();
}

query = Some(entry.unwrap());
}
}

write!(writer, "〉")?;

if terminate {
break;
} else if let Some(entry) = query {
write!(writer, "{}", entry)?;
}
}

terminal.restore()?;
println!("\n\nbye bye!");

Ok(())
}
53 changes: 21 additions & 32 deletions src/termio/escape.rs
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,6 @@ impl Action {
///
/// If this action is not a dispatch, this method returns `None`. Otherwise,
/// it returns a control other than [`Control::BEL`] and [`Control::ST`].
#[inline]
pub fn control(&self) -> Option<Control> {
use Action::*;
use Control::*;
Expand Down Expand Up @@ -887,16 +886,17 @@ const fn transition(state: State, byte: u8) -> (State, Action) {
/// </div>
/// <br>
///
/// Much better. I think. But there's so much code now. And a lot of it seems to
/// belong into `VtScanner`. After all, a parser of escape sequences shouldn't
/// struggle to count the number of bytes belonging to them. Furthermore, it
/// shouldn't have difficulties mapping corner cases to appropriate error kinds.
/// Much better. I think. There is too much code now. And a lot of it seems to
/// belong to `VtScanner`'s implementation. After all, a parser of escape
/// sequences should be able to count the number of bytes belonging to one and
/// also tell the first byte apart from the following bytes. Similarly, it
/// should be able to map various predicates to errors.
///
///
/// # Example #3: Without Boilerplate
///
/// After integrating the boilerplate code with `VtScanner`, the example
/// becomes:
/// After integrating that functionality with `VtScanner` and adding a few more
/// methods, the example becomes:
///
/// ```
/// # use std::io::{BufRead, Error, ErrorKind};
Expand Down Expand Up @@ -940,38 +940,29 @@ const fn transition(state: State, byte: u8) -> (State, Action) {
/// </div>
/// <br>
///
/// 🎉 Now we are talking!
///
/// [`VtScanner::processed`] returns the number of bytes processed so far while
/// recognizing an escape sequence. [`VtScanner::did_finish`] return `true` if
/// recognizing an escape sequence. [`VtScanner::did_finish`] returns `true` if
/// either [`VtScanner::did_abort`] or [`VtScanner::did_complete`] returns
/// `true`. Finally, [`VtScanner::finished_bytes`] and
/// [`VtScanner::finished_str`] return an escape sequence's payload as byte or
/// [`VtScanner::finished_str`] return an escape sequence's payload as a byte or
/// string slice—or an appropriate I/O error.
///
/// As it turns out, prettypretty's Rust version can do one better: The generic
/// [`VtScanner::scan_bytes`] and [`VtScanner::scan_str`] methods implement the
/// full loops. However, because they are generic methods, they also aren't
/// available in Python.
/// Prettypretty's Rust version can do one better: The generic
/// [`VtScanner::scan_bytes`] and [`VtScanner::scan_str`] methods encapsulate
/// the above double loop in its entirety. However, because they are generic
/// methods, they cannot be exposed to Python.
///
/// Still, concise and correct is good! 🎉
///
/// Ahem...
///
/// # Another Corner Case
///
/// The third version is much improved, though it still isn't ready for
/// production use. There is another corner case we haven't addressed. However,
/// this corner case has nothing to do with how we use `VtScanner`. Instead,
/// this corner case stems from the example using synchronous I/O. More
/// specifically, the problematic expression is the `input.fill_buf()` just
/// before the second loop in the above example code. It works, as long as at
/// least one byte is available for filling into the buffer. However, if no byte
/// is available, the method invocations blocks, waiting for more bytes to
/// become available. That may take a very very llloooonnnnngggggg time,
/// including practical infinity.
/// # One More Thing
///
/// The solution isn't necessarily switching to asynchronous I/O. A timeout
/// would suffice. A simple way of implementing that is to spawn a dedicated
/// reader thread that uses `std::sync::mpsc::sync_channel` (which does support
/// timeouts) to communicate with the main thread.
/// As discussed in the documentation for the [`termio`](crate::termio) module,
/// reading terminal input requires that the terminal has been correctly
/// configured and that reads eventually time out. As is, that won't happen with
/// for the `input.fill_buf()` just before the second loop.
#[cfg_attr(feature = "pyffi", pyclass(module = "prettypretty.color.termio"))]
#[derive(Debug)]
pub struct VtScanner {
Expand Down Expand Up @@ -1010,7 +1001,6 @@ impl VtScanner {
}

impl Default for VtScanner {
#[inline]
fn default() -> Self {
Self::with_capacity(Self::DEFAULT_CAPACITY)
}
Expand Down Expand Up @@ -1045,7 +1035,6 @@ impl VtScanner {
/// Unlike `Vec<T>`, a scanner's capacity does not change after creation.
/// This is a security precaution, since the bytes processed by this type
/// may originate from untrusted users.
#[inline]
pub fn capacity(&self) -> usize {
self.buffer.capacity()
}
Expand Down
108 changes: 78 additions & 30 deletions src/termio/mod.rs
Original file line number Diff line number Diff line change
@@ -1,45 +1,93 @@
//! Utilities for bringing your own terminal I/O.
//! Terminal integration.
//!
//! Bringing your own terminal I/O sounds great on paper. It doesn't force sync
//! or async I/O on your application. If async, it doesn't lock down the
//! (possibly wrong) runtime. In short, it promises flexibility straight out of
//! the crate. But the reality of bringing your own terminal I/O gets gnarly
//! real fast because the on-the-wire protocol has none of the niceties of
//! modern network protocols. It's just a stream of bytes with embedded ANSI
//! escape sequences.
//! Bringing your own terminal I/O sounds great at first. It doesn't force sync
//! or async I/O on your application. If your application is async, it doesn't
//! pick the runtime for you. In short, it promises flexibility straight out of
//! the crate. But without additional library support, bringing your own
//! terminal I/O also gets gnarly real fast. That's because command line
//! applications communicate with the terminal (really the terminal emulator)
//! through a protocol (bytes with embedded ANSI escape sequences) that has none
//! of the niceties of actual network protocols. Notably, it doesn't just lack
//! framing, but whether, say, 0x1b represents a press of the escape key or the
//! first byte of an ANSI escape sequence is entirely dependent on context.
//!
//! This module provides the low-level building blocks for processing this
//! protocol, i.e., writing and reading escape sequences:
//!
//! * [`VtScanner`] implements the state machines for recognizing ANSI escape
//! sequences.
//! * [`Control`] enumerates the different kinds of ANSI escape sequences and
//! their initial bytes.
//! * [`Action`] enumerates the different ways applications react to state
//! machine transitions.
//! # Integration of Terminal I/O
//!
//! The documentation for [`VtScanner`] includes example code for querying a
//! terminal for its theme colors and integrating with the
//! [`trans`](crate::trans) module's [`ThemeEntry`](crate::trans::ThemeEntry)
//! abstraction.
//! Producing a protocol stream is as simple as writing to standard output;
//! that's why the displays for [`Style`](crate::style::Style) and
//! [`ThemeEntry`](crate::trans::ThemeEntry) produce ANSI escape sequences.
//! However, consuming such a stream requires three features:
//!
//! 1. [`Terminal`] lets the application disable a terminal's line discipline
//! and restore it on exit again.
//! 2. [`TerminalReader`] provides the ability to read a terminal's input
//! stream without blocking indefinitely (with [`Terminal`] doing most of
//! the work).
//! 3. [`VtScanner`] parses a terminal's byte stream into characters and ANSI
//! escape sequences, while ignoring malformed byte sequences.
//!
//! # More Generally: Sans I/O
//! [`TerminalReader`] and the corresponding [`TerminalWriter`] are accessible
//! through [`Terminal::reader`] and [`Terminal::writer`]. Alas, like
//! [`Terminal`], they are only available on Unix-like operating systems
//! supported by the [libc](https://github.com/rust-lang/libc) crate. If your
//! application requires async I/O or Windows support, please consider using a
//! more fully-featured terminal crate such as
//! [Crossterm](https://github.com/crossterm-rs/crossterm).
//!
//!
//! # Timing Out Reads
//!
//! There are at least three different approaches to supporting timeouts when
//! reading from the terminal:
//!
//! 1. The first approach relies on the operating system's polling mechanism,
//! such as `epoll` or `kqueue`. However, polling for a single resource from
//! within a library seems like an antipattern. Also, macOS supports
//! `select` only when polling devices including terminals.
//! 2. The second approach uses a helper thread that uses blocking reads for
//! terminal input and forwards the data to a Rust channel (which supports
//! timeouts). This approach actually is nicely platform-independent. But
//! terminating the helper thread seems impossible, unless the operating
//! system's `TIOCSTI` ioctl or equivalent can be used to inject a poison
//! value into the input stream.
//! 3. The third approach configures the terminal to time out read operations.
//! Raw and cbreak modes for terminals usually set the `VMIN` pseudo control
//! character to 1 and `VTIME` to 0, which instructs the terminal to block
//! reads until at least one character is available. However, when setting
//! `VMIN` to 0 and `VTIME` to n>0, the terminal times out waiting after
//! n*0.1 seconds.
//!
//! This module implements the third approach because it is simple and robust.
//! Notably, it only requires a couple more changes to the terminal
//! configuration over and above the ones already required for cbreak or raw
//! mode. However, since it effectively polls the terminal, the third approach
//! also has higher CPU overhead. That is mitigated somewhat by the large
//! minimum timeout. Alas, that also puts a hard limit on reactivity.
//!
//!
//! # Sans I/O
//!
//! Prettypretty's Python version follows that community's "batteries included"
//! approach and includes a generally useful [terminal
//! abstraction](https://github.com/apparebit/prettypretty/blob/main/prettypretty/terminal.py).
//! By contrast, the Rust version requires the application to bring its own
//! terminal I/O. The latter approach, commonly called *Sans I/O* is recognized
//! by both [Python](https://sans-io.readthedocs.io) and
//! By contrast, the Rust version provides just enough functionality to query a
//! terminal for its color theme and only on Unix. If applications have more
//! complex needs, they are expected to bring their own terminal I/O. The latter
//! approach, commonly called *Sans I/O*, is recognized by both
//! [Python](https://sans-io.readthedocs.io) and
//! [Rust](https://www.firezone.dev/blog/sans-io) communities as an effective
//! means for coping with asynchronous I/O tainting functions throughout an
//! application (i.e., the function coloring challenge). Its value proposition
//! is simple: If we keep I/O out of library code, we can reuse the library with
//! synchronous and asynchronous I/O. With Sans I/O, library code still needs to
//! implement protocol processing, only now it provides a clean interface for
//! plugging the actual I/O routines.
//! means for building libraries that avoid the function coloring challenge
//! and equally work with synchronous and asynchronous I/O.
mod escape;
mod render;
#[cfg(target_family = "unix")]
mod unix;

pub use escape::{Action, Control, VtScanner};
pub use render::render;
#[cfg(target_family = "unix")]
pub use unix::{
Open, ReadWrite, Start, Terminal, TerminalReader, TerminalWriter, TERMINAL_TIMEOUT,
};
Loading

0 comments on commit cfa8d6a

Please sign in to comment.