Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions src/cursor/sys/unix.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use std::{
io::{self, Error, ErrorKind, Write},
io::{self, Error, ErrorKind},
time::Duration,
};

Expand Down Expand Up @@ -40,10 +40,8 @@ fn read_position_raw() -> io::Result<(u16, u16)> {
let _ = internal::read(&CursorPositionFilter);
}

// Use `ESC [ 6 n` to and retrieve the cursor position.
let mut stdout = io::stdout();
stdout.write_all(b"\x1B[6n")?;
stdout.flush()?;
// Use `ESC [ 6 n` to retrieve the cursor position.
crate::event::write_query(b"\x1B[6n")?;

loop {
match internal::poll(Some(Duration::from_millis(2000)), &CursorPositionFilter) {
Expand Down
13 changes: 13 additions & 0 deletions src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,19 @@ pub fn try_read() -> Option<Event> {
}
}

/// Writes a terminal query. Avoids writing to stdout when `use-dev-tty` is enabled since it may be piped.
#[cfg(unix)]
pub(crate) fn write_query(bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
#[cfg(feature = "use-dev-tty")]
let mut out = std::fs::OpenOptions::new().write(true).open("/dev/tty")?;
#[cfg(not(feature = "use-dev-tty"))]
let mut out = std::io::stdout();
out.write_all(bytes)?;
out.flush()?;
Ok(())
}

bitflags! {
/// Represents special flags that tell compatible terminals to add extra information to keyboard events.
///
Expand Down
8 changes: 4 additions & 4 deletions src/event/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ impl Filter for KeyboardEnhancementFlagsFilter {
// progressive keyboard enhancement.
matches!(
*event,
InternalEvent::KeyboardEnhancementFlags(_) | InternalEvent::PrimaryDeviceAttributes
InternalEvent::KeyboardEnhancementFlags(_) | InternalEvent::PrimaryDeviceAttributes(_)
)
}
}
Expand All @@ -42,7 +42,7 @@ pub(crate) struct PrimaryDeviceAttributesFilter;
#[cfg(unix)]
impl Filter for PrimaryDeviceAttributesFilter {
fn eval(&self, event: &InternalEvent) -> bool {
matches!(*event, InternalEvent::PrimaryDeviceAttributes)
matches!(*event, InternalEvent::PrimaryDeviceAttributes(_))
}
}

Expand Down Expand Up @@ -92,13 +92,13 @@ mod tests {
crate::event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
))
);
assert!(KeyboardEnhancementFlagsFilter.eval(&InternalEvent::PrimaryDeviceAttributes));
assert!(KeyboardEnhancementFlagsFilter.eval(&InternalEvent::PrimaryDeviceAttributes(vec![])));
}

#[test]
fn test_primary_device_attributes_filter_filters_primary_device_attributes() {
assert!(!PrimaryDeviceAttributesFilter.eval(&InternalEvent::Event(Event::Resize(10, 10))));
assert!(PrimaryDeviceAttributesFilter.eval(&InternalEvent::PrimaryDeviceAttributes));
assert!(PrimaryDeviceAttributesFilter.eval(&InternalEvent::PrimaryDeviceAttributes(vec![])));
}

#[test]
Expand Down
7 changes: 6 additions & 1 deletion src/event/internal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ pub(crate) enum InternalEvent {
#[cfg(unix)]
KeyboardEnhancementFlags(KeyboardEnhancementFlags),
/// Attributes and architectural class of the terminal.
///
/// The vec contains the `;`-separated parameters from `ESC [ ? … c`.
#[cfg(unix)]
PrimaryDeviceAttributes,
PrimaryDeviceAttributes(Vec<u16>),
/// DCS `ESC P > | <name> ESC \` response to an XTVERSION query (`ESC [ > q`).
#[cfg(unix)]
XtVersionResponse(String),
}
101 changes: 96 additions & 5 deletions src/event/sys/unix/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ pub(crate) fn parse_event(
}
}
b'[' => parse_csi(buffer),
b'P' => parse_dcs(buffer),
b'\x1B' => Ok(Some(InternalEvent::Event(Event::Key(KeyCode::Esc.into())))),
_ => parse_event(&buffer[1..], input_available).map(|event_option| {
event_option.map(|event| {
Expand Down Expand Up @@ -289,15 +290,20 @@ fn parse_csi_keyboard_enhancement_flags(buffer: &[u8]) -> io::Result<Option<Inte
}

fn parse_csi_primary_device_attributes(buffer: &[u8]) -> io::Result<Option<InternalEvent>> {
// ESC [ 64 ; attr1 ; attr2 ; ... ; attrn ; c
// ESC [ ? attr1 ; attr2 ; ... ; attrn c
assert!(buffer.starts_with(b"\x1B[?"));
assert!(buffer.ends_with(b"c"));

// This is a stub for parsing the primary device attributes. This response is not
// exposed in the crossterm API so we don't need to parse the individual attributes yet.
// See <https://vt100.net/docs/vt510-rm/DA1.html>
let s = std::str::from_utf8(&buffer[3..buffer.len() - 1])
.map_err(|_| could_not_parse_event_error())?;

let params: Vec<u16> = s
.split(';')
.filter(|p| !p.is_empty())
.map(|p| p.parse::<u16>().map_err(|_| could_not_parse_event_error()))
.collect::<io::Result<Vec<u16>>>()?;

Ok(Some(InternalEvent::PrimaryDeviceAttributes))
Ok(Some(InternalEvent::PrimaryDeviceAttributes(params)))
}

fn parse_modifiers(mask: u8) -> KeyModifiers {
Expand Down Expand Up @@ -861,6 +867,38 @@ pub(crate) fn parse_utf8_char(buffer: &[u8]) -> io::Result<Option<char>> {
}
}

fn parse_dcs(buffer: &[u8]) -> io::Result<Option<InternalEvent>> {
// DCS is `ESC P ... ESC \` (ST).
assert!(buffer.starts_with(b"\x1BP"));

let mut i = 2;
let end = loop {
if i >= buffer.len() {
return Ok(None);
}
if buffer[i] == b'\x1B' {
if i + 1 >= buffer.len() {
return Ok(None);
}
if buffer[i + 1] == b'\\' {
break i;
}
}
i += 1;
};

// XTVERSION response: `ESC P > | <version string> ESC \`
let payload = &buffer[2..end];
if let Some(version_bytes) = payload.strip_prefix(b">|") {
let version = std::str::from_utf8(version_bytes)
.map_err(|_| could_not_parse_event_error())?
.to_owned();
return Ok(Some(InternalEvent::XtVersionResponse(version)));
}

Err(could_not_parse_event_error())
}

#[cfg(test)]
mod tests {
use crate::event::{KeyEventState, KeyModifiers, MouseButton, MouseEvent};
Expand Down Expand Up @@ -1503,4 +1541,57 @@ mod tests {
)))),
);
}

#[test]
fn test_parse_csi_primary_device_attributes_empty() {
assert_eq!(
parse_csi_primary_device_attributes(b"\x1B[?c").unwrap(),
Some(InternalEvent::PrimaryDeviceAttributes(vec![])),
);
}

#[test]
fn test_parse_csi_primary_device_attributes_multi() {
assert_eq!(
parse_csi_primary_device_attributes(b"\x1B[?64;1;2;6;9;15;22c").unwrap(),
Some(InternalEvent::PrimaryDeviceAttributes(vec![64, 1, 2, 6, 9, 15, 22])),
);
}

#[test]
fn test_parse_csi_primary_device_attributes_trailing_semicolon() {
assert_eq!(
parse_csi_primary_device_attributes(b"\x1B[?1;2;c").unwrap(),
Some(InternalEvent::PrimaryDeviceAttributes(vec![1, 2])),
);
}

#[test]
fn test_parse_csi_primary_device_attributes_malformed() {
assert!(parse_csi_primary_device_attributes(b"\x1B[?abc").is_err());
assert!(parse_csi_primary_device_attributes(b"\x1B[?99999999c").is_err());
}

#[test]
fn test_parse_dcs_xtversion() {
assert_eq!(
parse_event(b"\x1BP>|tmux 3.3\x1B\\", false).unwrap(),
Some(InternalEvent::XtVersionResponse("tmux 3.3".to_owned())),
);
assert_eq!(
parse_event(b"\x1BP>|kitty 0.36.4\x1B\\", false).unwrap(),
Some(InternalEvent::XtVersionResponse("kitty 0.36.4".to_owned())),
);
}

#[test]
fn test_parse_dcs_incomplete() {
assert_eq!(parse_event(b"\x1BP>|tmux 3.3", true).unwrap(), None);
assert_eq!(parse_event(b"\x1BP>|tmux 3.3\x1B", true).unwrap(), None);
}

#[test]
fn test_parse_dcs_unknown_rejected() {
assert!(parse_event(b"\x1BPXfoo\x1B\\", false).is_err());
}
}
3 changes: 3 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,9 @@ pub mod cursor;
/// A module to read events.
#[cfg(feature = "events")]
pub mod event;
/// A module to send batched terminal capability queries.
#[cfg(all(unix, feature = "events"))]
pub mod query;
/// A module to apply attributes and colors on your text.
pub mod style;
/// A module to work with the terminal.
Expand Down
156 changes: 156 additions & 0 deletions src/query.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
//! Batched terminal capability querying.
//!
//! Sends multiple terminal queries in a single write and collects all responses
//! in one read pass, using DA1 (`ESC [ c`) as a sentinel that follows the
//! batched queries.
//!
//! Concrete query types live in their respective capability modules.

use std::{io, time::Duration};

use crate::event::{
filter::Filter,
internal::{self, InternalEvent},
};

/// A terminal capability query that can be issued via [`QueryBatch`].
#[allow(private_interfaces)]
pub trait TerminalQuery: Clone + Send + Sync + 'static {
type Response;
fn query_bytes(&self) -> Vec<u8>;
fn matches(&self, event: &InternalEvent) -> bool;
fn extract(&self, event: Option<InternalEvent>) -> io::Result<Self::Response>;
}

/// A typed handle to retrieve one query's result from a [`QueryResults`].
///
/// Obtained by calling [`QueryBatch::add`].
pub struct QueryHandle<T> {
idx: usize,
extract: Box<dyn Fn(Option<InternalEvent>) -> io::Result<T>>,
}

/// Results returned by [`QueryBatch::execute`].
pub struct QueryResults {
results: Vec<Option<InternalEvent>>,
}

impl QueryResults {
/// Extracts the response for the given handle.
pub fn get<T>(&self, handle: &QueryHandle<T>) -> io::Result<T> {
(handle.extract)(self.results[handle.idx].clone())
}
}

struct BatchFilter {
matchers: Vec<Box<dyn Fn(&InternalEvent) -> bool + Send + Sync>>,
}

impl Filter for BatchFilter {
fn eval(&self, event: &InternalEvent) -> bool {
// Always pass DA1; it ends the batch read loop.
matches!(event, InternalEvent::PrimaryDeviceAttributes(_))
|| self.matchers.iter().any(|m| m(event))
}
}

/// Sends multiple terminal queries in one write and collects all responses.
///
/// All queries are written together followed by a DA1 sentinel (`ESC [ c`).
/// Reads until the DA1 reply arrives or the timeout expires; any queries that
/// haven't responded by then are returned as `None`.
///
/// Concrete query types are defined in capability modules (e.g. `colors`,
/// `graphics`).
///
/// # Example
///
/// ```no_run
/// # #[cfg(unix)] {
/// use crossterm::query::QueryBatch;
///
/// let mut batch = QueryBatch::new();
/// // batch.add(SomeQuery) for each capability query you want to issue
/// let _results = batch.execute()?;
/// # }
/// # Ok::<(), std::io::Error>(())
/// ```
pub struct QueryBatch {
/// How long [`execute`](Self::execute) waits for the DA1 reply before giving up.
pub timeout: Duration,
bytes: Vec<Vec<u8>>,
matchers: Vec<Box<dyn Fn(&InternalEvent) -> bool + Send + Sync>>,
results: Vec<Option<InternalEvent>>,
}

impl Default for QueryBatch {
fn default() -> Self {
Self::new()
}
}

impl QueryBatch {
pub fn new() -> Self {
Self {
timeout: Duration::from_secs(2),
bytes: Vec::new(),
matchers: Vec::new(),
results: Vec::new(),
}
}

/// Builder-style setter for the [`timeout`](field@Self::timeout) field.
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}

/// Registers a query and returns a handle for retrieving its result.
pub fn add<Q: TerminalQuery>(&mut self, query: Q) -> QueryHandle<Q::Response> {
let idx = self.bytes.len();
let q = query.clone();
self.bytes.push(query.query_bytes());
self.matchers.push(Box::new(move |e| q.matches(e)));
self.results.push(None);
QueryHandle {
idx,
extract: Box::new(move |e| query.extract(e)),
}
}

/// Sends all queries plus a DA1 sentinel and reads until the DA1 reply arrives.
pub fn execute(mut self) -> io::Result<QueryResults> {
let filter = BatchFilter {
matchers: self.matchers,
};

// Drain any stale responses from prior queries.
while internal::poll(Some(Duration::ZERO), &filter)? {
internal::read(&filter)?;
}

let mut bytes: Vec<u8> = self.bytes.into_iter().flatten().collect();
bytes.extend_from_slice(b"\x1B[c"); // DA1 sentinel
crate::event::write_query(&bytes)?;

loop {
if !internal::poll(Some(self.timeout), &filter)? {
break;
}
let event = internal::read(&filter)?;
let is_da1 = matches!(event, InternalEvent::PrimaryDeviceAttributes(_));
for (matcher, result) in filter.matchers.iter().zip(self.results.iter_mut()) {
if matcher(&event) && result.is_none() {
*result = Some(event.clone());
}
}
if is_da1 {
break;
}
}

Ok(QueryResults {
results: self.results,
})
}
}
Loading