From 3915728e61c36b0708e36815fa8e10015d714274 Mon Sep 17 00:00:00 2001 From: Funami580 <63090225+Funami580@users.noreply.github.com> Date: Sat, 30 Dec 2023 23:45:25 +0100 Subject: [PATCH 1/3] support for synchronized output --- src/unix_term.rs | 289 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 289 insertions(+) diff --git a/src/unix_term.rs b/src/unix_term.rs index 271709f2..9e1e00b0 100644 --- a/src/unix_term.rs +++ b/src/unix_term.rs @@ -5,6 +5,9 @@ use std::io; use std::io::{BufRead, BufReader}; use std::mem; use std::os::unix::io::AsRawFd; +use std::ptr; +use std::os::unix::io::FromRawFd; +use std::os::unix::io::IntoRawFd; use std::str; use crate::kb::Key; @@ -363,3 +366,289 @@ pub fn wants_emoji() -> bool { pub fn set_title(title: T) { print!("\x1b]0;{}\x07", title); } + +fn with_raw_terminal(f: impl FnOnce(&mut fs::File) -> R) -> io::Result { + // We need a custom drop implementation for File, + // so that the fd for stdin does not get closed + enum CustomDropFile { + CloseFd(Option), + NotCloseFd(Option), + } + + impl Drop for CustomDropFile { + fn drop(&mut self) { + match self { + CustomDropFile::CloseFd(_) => {} + CustomDropFile::NotCloseFd(inner) => { + if let Some(file) = inner.take() { + file.into_raw_fd(); + } + } + } + } + } + + let (mut tty_handle, tty_fd) = if unsafe { libc::isatty(libc::STDIN_FILENO) } == 1 { + ( + CustomDropFile::NotCloseFd(Some(unsafe { fs::File::from_raw_fd(libc::STDIN_FILENO) })), + libc::STDIN_FILENO, + ) + } else { + let handle = fs::OpenOptions::new() + .read(true) + .write(true) + .open("/dev/tty")?; + let fd = handle.as_raw_fd(); + (CustomDropFile::CloseFd(Some(handle)), fd) + }; + + // Get current mode + let mut termios = mem::MaybeUninit::uninit(); + c_result(|| unsafe { libc::tcgetattr(tty_fd, termios.as_mut_ptr()) })?; + + let mut termios = unsafe { termios.assume_init() }; + let old_iflag = termios.c_iflag; + let old_oflag = termios.c_oflag; + let old_cflag = termios.c_cflag; + let old_lflag = termios.c_lflag; + + // Go into raw mode + unsafe { libc::cfmakeraw(&mut termios) }; + if old_lflag & libc::ISIG != 0 { + // Re-enable INTR, QUIT, SUSP, DSUSP, if it was activated before + termios.c_lflag |= libc::ISIG; + } + c_result(|| unsafe { libc::tcsetattr(tty_fd, libc::TCSADRAIN, &termios) })?; + + let result = match &mut tty_handle { + CustomDropFile::CloseFd(Some(handle)) => f(handle), + CustomDropFile::NotCloseFd(Some(handle)) => f(handle), + _ => unreachable!(), + }; + + // Reset to previous mode + termios.c_iflag = old_iflag; + termios.c_oflag = old_oflag; + termios.c_cflag = old_cflag; + termios.c_lflag = old_lflag; + c_result(|| unsafe { libc::tcsetattr(tty_fd, libc::TCSADRAIN, &termios) })?; + + Ok(result) +} + +pub fn supports_synchronized_output() -> bool { + *sync_output::SUPPORTS_SYNCHRONIZED_OUTPUT +} + +/// Specification: https://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036 +mod sync_output { + use std::convert::TryInto as _; + use std::io::Read as _; + use std::io::Write as _; + use std::os::unix::io::AsRawFd as _; + use std::time; + + use lazy_static::lazy_static; + + use super::select_or_poll_term_fd; + use super::with_raw_terminal; + + const RESPONSE_TIMEOUT: time::Duration = time::Duration::from_millis(10); + + lazy_static! { + pub(crate) static ref SUPPORTS_SYNCHRONIZED_OUTPUT: bool = + supports_synchronized_output_uncached(); + } + + struct ResponseParser { + state: ResponseParserState, + response: u8, + } + + #[derive(PartialEq)] + enum ResponseParserState { + None, + CsiOne, + CsiTwo, + QuestionMark, + ModeDigit1, + ModeDigit2, + ModeDigit3, + ModeDigit4, + Semicolon, + Response, + DollarSign, + Ypsilon, + } + + impl ResponseParser { + const fn new() -> Self { + Self { + state: ResponseParserState::None, + response: u8::MAX, + } + } + + fn process_byte(&mut self, byte: u8) { + match byte { + b'\x1b' => { + self.state = ResponseParserState::CsiOne; + } + b'[' => { + self.state = if self.state == ResponseParserState::CsiOne { + ResponseParserState::CsiTwo + } else { + ResponseParserState::None + }; + } + b'?' => { + self.state = if self.state == ResponseParserState::CsiTwo { + ResponseParserState::QuestionMark + } else { + ResponseParserState::None + }; + } + byte @ b'0' => { + self.state = if self.state == ResponseParserState::Semicolon { + self.response = byte; + ResponseParserState::Response + } else if self.state == ResponseParserState::ModeDigit1 { + ResponseParserState::ModeDigit2 + } else { + ResponseParserState::None + }; + } + byte @ b'2' => { + self.state = if self.state == ResponseParserState::Semicolon { + self.response = byte; + ResponseParserState::Response + } else if self.state == ResponseParserState::QuestionMark { + ResponseParserState::ModeDigit1 + } else if self.state == ResponseParserState::ModeDigit2 { + ResponseParserState::ModeDigit3 + } else { + ResponseParserState::None + }; + } + byte @ b'1' | byte @ b'3' | byte @ b'4' => { + self.state = if self.state == ResponseParserState::Semicolon { + self.response = byte; + ResponseParserState::Response + } else { + ResponseParserState::None + }; + } + b'6' => { + self.state = if self.state == ResponseParserState::ModeDigit3 { + ResponseParserState::ModeDigit4 + } else { + ResponseParserState::None + }; + } + b';' => { + self.state = if self.state == ResponseParserState::ModeDigit4 { + ResponseParserState::Semicolon + } else { + ResponseParserState::None + }; + } + b'$' => { + self.state = if self.state == ResponseParserState::Response { + ResponseParserState::DollarSign + } else { + ResponseParserState::None + }; + } + b'y' => { + self.state = if self.state == ResponseParserState::DollarSign { + ResponseParserState::Ypsilon + } else { + ResponseParserState::None + }; + } + _ => { + self.state = ResponseParserState::None; + } + } + } + + fn get_response(&self) -> Option { + if self.state == ResponseParserState::Ypsilon { + Some(self.response - b'0') + } else { + None + } + } + } + + fn supports_synchronized_output_uncached() -> bool { + with_raw_terminal(|term_handle| { + // Query the state of the (DEC) mode 2026 (Synchronized Output) + write!(term_handle, "\x1b[?2026$p").ok()?; + term_handle.flush().ok()?; + + // Wait for response or timeout + let term_fd = term_handle.as_raw_fd(); + let mut parser = ResponseParser::new(); + let mut buf = [0u8; 256]; + let deadline = time::Instant::now() + RESPONSE_TIMEOUT; + + loop { + let remaining_time = deadline + .saturating_duration_since(time::Instant::now()) + .as_millis() + .try_into() + .ok()?; + + if remaining_time == 0 { + // Timeout + return Some(false); + } + + match select_or_poll_term_fd(term_fd, remaining_time) { + Ok(false) => { + // Timeout + return Some(false); + } + Ok(true) => { + 'read: loop { + match term_handle.read(&mut buf) { + Ok(0) => { + // Reached EOF + return Some(false); + } + Ok(size) => { + for byte in &buf[..size] { + parser.process_byte(*byte); + + match parser.get_response() { + Some(1) | Some(2) => return Some(true), + Some(_) => return Some(false), + None => {} + } + } + + break 'read; + } + Err(err) if err.kind() == std::io::ErrorKind::Interrupted => { + // Got interrupted, retry read + continue 'read; + } + Err(_) => { + return Some(false); + } + } + } + } + Err(_) => { + // Error + return Some(false); + } + } + } + }) + .ok() + .flatten() + .unwrap_or(false) + } +} From 5d0d3ea2540f4c32bef1e7f27d46f912a6e0e515 Mon Sep 17 00:00:00 2001 From: Chris Laplante Date: Mon, 5 Feb 2024 15:02:21 -0500 Subject: [PATCH 2/3] first crack at adding SyncGuard from Funami580 Co-authored-by: Funami580 <63090225+Funami580@users.noreply.github.com> --- src/lib.rs | 2 +- src/term.rs | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index a1ac2275..ebc5ec39 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -78,7 +78,7 @@ pub use crate::kb::Key; pub use crate::term::{ - user_attended, user_attended_stderr, Term, TermFamily, TermFeatures, TermTarget, + user_attended, user_attended_stderr, SyncGuard, Term, TermFamily, TermFeatures, TermTarget, }; pub use crate::utils::{ colors_enabled, colors_enabled_stderr, measure_text_width, pad_str, pad_str_with, diff --git a/src/term.rs b/src/term.rs index 44e94055..4ea1459e 100644 --- a/src/term.rs +++ b/src/term.rs @@ -1,3 +1,4 @@ +use std::cell::Cell; use std::fmt::{Debug, Display}; use std::io::{self, Read, Write}; use std::sync::{Arc, Mutex, RwLock}; @@ -78,6 +79,19 @@ impl<'a> TermFeatures<'a> { is_a_color_terminal(self.0) } + #[inline] + pub fn is_synchronized_output_supported(&self) -> bool { + #[cfg(unix)] + { + supports_synchronized_output() + } + #[cfg(not(unix))] + { + // TODO + false + } + } + /// Check if this terminal is an msys terminal. /// /// This is sometimes useful to disable features that are known to not @@ -656,6 +670,42 @@ impl<'a> Read for &'a Term { } } +pub struct SyncGuard<'a> { + term: Cell>, +} + +impl<'a> SyncGuard<'a> { + pub fn begin_sync(term: &'a Term) -> io::Result { + let ret = if term.features().is_synchronized_output_supported() { + term.write_str("\x1b[?2026h")?; + Some(term) + } else { + None + }; + + Ok(Self { + term: Cell::new(ret), + }) + } + + pub fn finish_sync(self) -> io::Result<()> { + self.finish_sync_inner() + } + + fn finish_sync_inner(&self) -> io::Result<()> { + if let Some(term) = self.term.take() { + term.write_str("\x1b[?2026l")?; + } + Ok(()) + } +} + +impl Drop for SyncGuard<'_> { + fn drop(&mut self) { + let _ = self.finish_sync_inner(); + } +} + #[cfg(all(unix, not(target_arch = "wasm32")))] pub use crate::unix_term::*; #[cfg(target_arch = "wasm32")] From bbd9e2cf2ff711455ed0e8039e078988a5f9802f Mon Sep 17 00:00:00 2001 From: Funami580 <63090225+Funami580@users.noreply.github.com> Date: Tue, 6 Feb 2024 16:28:44 +0100 Subject: [PATCH 3/3] unix_term: conditionally use std::ptr for macOS --- src/unix_term.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/unix_term.rs b/src/unix_term.rs index 9e1e00b0..ad43be0e 100644 --- a/src/unix_term.rs +++ b/src/unix_term.rs @@ -5,7 +5,6 @@ use std::io; use std::io::{BufRead, BufReader}; use std::mem; use std::os::unix::io::AsRawFd; -use std::ptr; use std::os::unix::io::FromRawFd; use std::os::unix::io::IntoRawFd; use std::str; @@ -122,6 +121,8 @@ fn poll_fd(fd: i32, timeout: i32) -> io::Result { #[cfg(target_os = "macos")] fn select_fd(fd: i32, timeout: i32) -> io::Result { + use std::ptr; + unsafe { let mut read_fd_set: libc::fd_set = mem::zeroed();