#![doc(html_favicon_url = "https://watchexec.github.io/logo:clearscreen.svg")]
#![doc(html_logo_url = "https://watchexec.github.io/logo:clearscreen.svg")]
#![warn(missing_docs)]
use std::{
borrow::Cow,
env,
io::{self, Write},
process::{Command, ExitStatus},
};
use terminfo::{
capability::{self},
expand::{Context, Expand, Parameter},
Capability, Database, Value,
};
use thiserror::Error;
use which::which;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum ClearScreen {
Terminfo,
TerminfoScreen,
TerminfoScrollback,
TerminfoReset,
XtermClear,
XtermReset,
TputClear,
TputReset,
Cls,
WindowsConsoleClear,
#[cfg(feature = "windows-console")]
WindowsConsoleBlank,
WindowsCooked,
VtRis,
VtLeaveAlt,
VtCooked,
VtWellDone,
}
impl Default for ClearScreen {
fn default() -> Self {
use env::var;
use std::ffi::OsStr;
fn varfull(key: impl AsRef<OsStr>) -> bool {
var(key).map_or(false, |s| !s.is_empty())
}
let term = var("TERM").ok();
let term = term.as_ref();
if cfg!(windows) {
return if is_microsoft_terminal() {
Self::XtermClear
} else if term.is_some() && varfull("TERMINFO") {
Self::Terminfo
} else if term.is_some() && which("tput").is_ok() {
Self::TputClear
} else {
Self::Cls
};
}
if let Some(term) = term {
if (term.starts_with("gnome")
&& varfull("GNOME_TERMINAL_SCREEN")
&& varfull("GNOME_TERMINAL_SERVICE"))
|| term == "xfce"
|| term.contains("termite")
{
return Self::XtermClear;
}
if term == "syncterm"
|| term.contains("rxvt")
|| term.contains("kitty")
|| var("CHROME_DESKTOP").map_or(false, |cd| cd == "tess.desktop")
|| varfull("ZUTTY_VERSION")
|| varfull("ZELLIJ")
{
return Self::VtRis;
}
if term.starts_with("screen")
|| term.starts_with("konsole")
|| term == "wezterm"
|| term.starts_with("tmux")
{
return Self::XtermClear;
}
if cfg!(target_os = "macos")
&& term.starts_with("xterm")
&& Database::from_env()
.map(|info| info.get::<ResetScrollback>().is_none())
.unwrap_or(true)
{
return Self::XtermClear;
}
if !term.is_empty() && Database::from_env().is_ok() {
return Self::Terminfo;
}
}
Self::XtermClear
}
}
const ESC: &[u8] = b"\x1b";
const CSI: &[u8] = b"\x1b[";
const RIS: &[u8] = b"c";
impl ClearScreen {
pub fn clear(self) -> Result<(), Error> {
let mut stdout = io::stdout();
self.clear_to(&mut stdout)
}
pub fn clear_to(self, mut w: &mut impl Write) -> Result<(), Error> {
#[cfg(windows)]
let (no_vt, _vt_guard) = match win::vt() {
Ok(win::VtSupport::Enabled(guard)) => (false, Some(guard)),
Ok(win::VtSupport::NoVt) => (true, None),
_ => (false, None),
};
match self {
Self::Terminfo => {
let info = Database::from_env()?;
let mut ctx = Context::default();
if let Some(seq) = info.get::<capability::ClearScreen>() {
seq.expand().with(&mut ctx).to(&mut w)?;
w.flush()?;
} else {
return Err(Error::TerminfoCap("clear"));
}
if let Some(seq) = info.get::<ResetScrollback>() {
seq.expand_to(&mut w, Some(&mut ctx))?;
w.flush()?;
}
}
Self::TerminfoScreen => {
let info = Database::from_env()?;
if let Some(seq) = info.get::<capability::ClearScreen>() {
seq.expand().to(&mut w)?;
w.flush()?;
} else {
return Err(Error::TerminfoCap("clear"));
}
}
Self::TerminfoScrollback => {
let info = Database::from_env()?;
if let Some(seq) = info.get::<ResetScrollback>() {
seq.expand_to(&mut w, None)?;
w.flush()?;
} else {
return Err(Error::TerminfoCap("E3"));
}
}
Self::TerminfoReset => {
let info = Database::from_env()?;
let mut ctx = Context::default();
let mut reset = false;
if let Some(seq) = info.get::<capability::Reset1String>() {
reset = true;
seq.expand().with(&mut ctx).to(&mut w)?;
}
if let Some(seq) = info.get::<capability::Reset2String>() {
reset = true;
seq.expand().with(&mut ctx).to(&mut w)?;
}
if let Some(seq) = info.get::<capability::Reset3String>() {
reset = true;
seq.expand().with(&mut ctx).to(&mut w)?;
}
if let Some(seq) = info.get::<capability::ResetFile>() {
reset = true;
seq.expand().with(&mut ctx).to(&mut w)?;
}
w.flush()?;
if reset {
return Ok(());
}
if let Some(seq) = info.get::<capability::Init1String>() {
reset = true;
seq.expand().with(&mut ctx).to(&mut w)?;
}
if let Some(seq) = info.get::<capability::Init2String>() {
reset = true;
seq.expand().with(&mut ctx).to(&mut w)?;
}
if let Some(seq) = info.get::<capability::Init3String>() {
reset = true;
seq.expand().with(&mut ctx).to(&mut w)?;
}
if let Some(seq) = info.get::<capability::InitFile>() {
reset = true;
seq.expand().with(&mut ctx).to(&mut w)?;
}
w.flush()?;
if !reset {
return Err(Error::TerminfoCap("reset"));
}
}
Self::XtermClear => {
#[cfg(windows)]
if no_vt {
return win::clear();
}
const CURSOR_HOME: &[u8] = b"H";
const ERASE_SCREEN: &[u8] = b"2J";
const ERASE_SCROLLBACK: &[u8] = b"3J";
w.write_all(CSI)?;
w.write_all(CURSOR_HOME)?;
w.write_all(CSI)?;
w.write_all(ERASE_SCREEN)?;
w.write_all(CSI)?;
w.write_all(ERASE_SCROLLBACK)?;
w.flush()?;
}
Self::XtermReset => {
#[cfg(windows)]
if no_vt {
return win::clear();
}
const STR: &[u8] = b"!p";
const RESET_WIDTH_AND_SCROLL: &[u8] = b"?3;4l";
const RESET_REPLACE: &[u8] = b"4l";
const RESET_KEYPAD: &[u8] = b">";
const RESET_MARGINS: &[u8] = b"?69l";
w.write_all(ESC)?;
w.write_all(RIS)?;
w.write_all(CSI)?;
w.write_all(STR)?;
w.write_all(CSI)?;
w.write_all(RESET_WIDTH_AND_SCROLL)?;
w.write_all(CSI)?;
w.write_all(RESET_REPLACE)?;
w.write_all(ESC)?;
w.write_all(RESET_KEYPAD)?;
w.write_all(CSI)?;
w.write_all(RESET_MARGINS)?;
w.flush()?;
}
Self::TputClear => {
let status = Command::new("tput").arg("clear").status()?;
if !status.success() {
return Err(Error::Command("tput clear", status));
}
}
Self::TputReset => {
let status = Command::new("tput").arg("reset").status()?;
if !status.success() {
return Err(Error::Command("tput reset", status));
}
}
Self::Cls => {
let status = Command::new("cmd.exe").arg("/C").arg("cls").status()?;
if !status.success() {
return Err(Error::Command("cls", status));
}
}
Self::WindowsConsoleClear => win::clear()?,
#[cfg(feature = "windows-console")]
Self::WindowsConsoleBlank => win::blank()?,
Self::WindowsCooked => win::cooked()?,
Self::VtRis => {
w.write_all(ESC)?;
w.write_all(RIS)?;
w.flush()?;
}
Self::VtLeaveAlt => {
const LEAVE_ALT: &[u8] = b"?1049l";
w.write_all(CSI)?;
w.write_all(LEAVE_ALT)?;
w.flush()?;
}
Self::VtCooked => unix::vt_cooked()?,
Self::VtWellDone => unix::vt_well_done()?,
}
Ok(())
}
}
pub fn clear() -> Result<(), Error> {
ClearScreen::default().clear()
}
pub fn is_microsoft_terminal() -> bool {
win::vt_supported()
}
#[derive(Debug, Error)]
pub enum Error {
#[error("io: {0}")]
Io(#[from] io::Error),
#[error("command: {0}: {1}")]
Command(&'static str, ExitStatus),
#[cfg(unix)]
#[error("unix: {0}")]
Nix(#[from] NixError),
#[error("terminfo: {0}")]
Terminfo(#[from] TerminfoError),
#[error("terminfo: capability not available: {0}")]
TerminfoCap(&'static str),
#[error("ffi: encountered a null pointer while reading {0}")]
NullPtr(&'static str),
}
#[cfg(unix)]
#[derive(Debug, Error)]
#[error(transparent)]
pub struct NixError(nix::Error);
#[cfg(unix)]
impl From<NixError> for i32 {
fn from(err: NixError) -> Self {
err.0 as _
}
}
#[derive(Debug, Error)]
#[error("{description}")]
pub struct TerminfoError {
inner: terminfo::Error,
description: String,
}
impl From<terminfo::Error> for TerminfoError {
fn from(inner: terminfo::Error) -> Self {
Self {
description: inner.to_string(),
inner,
}
}
}
impl From<terminfo::Error> for Error {
fn from(err: terminfo::Error) -> Self {
Self::Terminfo(TerminfoError::from(err))
}
}
#[cfg(unix)]
mod unix {
use super::{Error, NixError};
use nix::{
sys::termios::{
tcgetattr, tcsetattr, ControlFlags, InputFlags, LocalFlags, OutputFlags,
SetArg::TCSANOW, Termios,
},
unistd::isatty,
};
use std::{
fs::OpenOptions,
io::stdin,
os::fd::{AsFd, BorrowedFd},
};
pub(crate) fn vt_cooked() -> Result<(), Error> {
write_termios(cooked_mode)
}
pub(crate) fn vt_well_done() -> Result<(), Error> {
write_termios(well_done_mode)
}
fn cooked_mode(t: &mut Termios) {
t.input_flags
.insert(InputFlags::BRKINT | InputFlags::ICRNL | InputFlags::IGNPAR | InputFlags::IXON);
t.output_flags.insert(OutputFlags::OPOST);
t.local_flags.insert(usable_local_flags());
}
fn well_done_mode(t: &mut Termios) {
#[allow(unused_mut)]
let mut inserts = InputFlags::BRKINT | InputFlags::ICRNL | InputFlags::IGNPAR | InputFlags::IXON;
#[cfg(any(target_os = "android", target_os = "linux", target_os = "macos"))]
{
inserts |= InputFlags::IUTF8;
}
#[cfg(not(target_os = "redox"))]
{
inserts |= InputFlags::IMAXBEL;
}
t.input_flags.insert(inserts);
t.output_flags
.insert(OutputFlags::ONLCR | OutputFlags::OPOST);
t.control_flags.insert(ControlFlags::CREAD);
t.local_flags.insert(usable_local_flags());
}
fn usable_local_flags() -> LocalFlags {
let mut flags = LocalFlags::ICANON
| LocalFlags::ISIG
| LocalFlags::IEXTEN
| LocalFlags::ECHO
| LocalFlags::ECHOE
| LocalFlags::ECHOK;
#[cfg(not(target_os = "redox"))]
{
flags |= LocalFlags::ECHOCTL | LocalFlags::ECHOKE;
}
flags
}
fn line_configuration() -> ControlFlags {
#[allow(unused_mut)]
let mut keep = ControlFlags::CSIZE;
#[cfg(any(target_os = "android", target_os = "linux"))]
{
keep |= ControlFlags::CBAUD;
}
keep
}
fn reset_termios(t: &mut Termios) {
t.input_flags.remove(InputFlags::all());
t.output_flags.remove(OutputFlags::all());
t.control_flags
.remove(ControlFlags::all().difference(line_configuration()));
t.local_flags.remove(LocalFlags::all());
}
fn write_termios(f: impl Fn(&mut Termios)) -> Result<(), Error> {
if isatty(stdin()).map_err(NixError)? {
write_termios_to(stdin().as_fd(), f)
} else {
let tty = OpenOptions::new().read(true).write(true).open("/dev/tty")?;
write_termios_to(tty.as_fd(), f)
}
}
fn write_termios_to(fd: BorrowedFd<'_>, f: impl Fn(&mut Termios)) -> Result<(), Error> {
let mut t = tcgetattr(fd).map_err(NixError)?;
reset_termios(&mut t);
f(&mut t);
tcsetattr(fd, TCSANOW, &t).map_err(NixError)?;
Ok(())
}
#[cfg(all(
test,
not(any(target_os = "redox", target_os = "fuchsia", target_os = "aix"))
))]
mod tests {
use super::*;
use nix::pty::openpty;
fn sane_pty() -> nix::pty::OpenptyResult {
let pty = openpty(None, None).expect("openpty");
let mut t = tcgetattr(&pty.slave).expect("tcgetattr");
t.local_flags.insert(usable_local_flags());
t.input_flags.remove(InputFlags::ISTRIP);
t.control_flags.remove(ControlFlags::CSIZE);
t.control_flags.insert(ControlFlags::CS8);
tcsetattr(&pty.slave, TCSANOW, &t).expect("tcsetattr");
pty
}
fn each_mode() -> [(&'static str, fn(&mut Termios)); 2] {
[
("vt_cooked", cooked_mode as fn(&mut Termios)),
("vt_well_done", well_done_mode),
]
}
#[test]
fn echo_survives() {
for (name, mode) in each_mode() {
let pty = sane_pty();
write_termios_to(pty.slave.as_fd(), mode).expect("write_termios_to");
let after = tcgetattr(&pty.slave).expect("tcgetattr");
for flag in [
LocalFlags::ECHO,
LocalFlags::ECHOE,
LocalFlags::ECHOK,
LocalFlags::ECHOCTL,
LocalFlags::ECHOKE,
] {
assert!(
after.local_flags.contains(flag),
"{name} left the terminal without {flag:?}"
);
}
assert!(
after.local_flags.contains(LocalFlags::IEXTEN),
"{name} left the terminal without IEXTEN"
);
assert!(after.local_flags.contains(LocalFlags::ICANON), "{name}");
assert!(after.local_flags.contains(LocalFlags::ISIG), "{name}");
}
}
#[test]
fn the_eighth_bit_is_not_stripped() {
for (name, mode) in each_mode() {
let pty = sane_pty();
write_termios_to(pty.slave.as_fd(), mode).expect("write_termios_to");
let after = tcgetattr(&pty.slave).expect("tcgetattr");
assert!(
!after.input_flags.contains(InputFlags::ISTRIP),
"{name} set ISTRIP, so UTF-8 input cannot survive"
);
}
}
#[test]
fn the_line_configuration_is_left_alone() {
for (name, mode) in each_mode() {
let pty = sane_pty();
let before = tcgetattr(&pty.slave).expect("tcgetattr");
write_termios_to(pty.slave.as_fd(), mode).expect("write_termios_to");
let after = tcgetattr(&pty.slave).expect("tcgetattr");
assert_eq!(
after.control_flags & line_configuration(),
before.control_flags & line_configuration(),
"{name} changed the character size or line speed"
);
assert!(
after.control_flags.contains(ControlFlags::CS8),
"{name} left the terminal at fewer than 8 bits per character"
);
}
}
#[test]
fn a_terminal_already_without_echo_is_recovered() {
for (name, mode) in each_mode() {
let pty = sane_pty();
let mut t = tcgetattr(&pty.slave).expect("tcgetattr");
t.local_flags.remove(LocalFlags::all());
tcsetattr(&pty.slave, TCSANOW, &t).expect("tcsetattr");
write_termios_to(pty.slave.as_fd(), mode).expect("write_termios_to");
let after = tcgetattr(&pty.slave).expect("tcgetattr");
assert!(
after.local_flags.contains(LocalFlags::ECHO),
"{name} did not restore echo"
);
}
}
}
}
#[cfg(windows)]
mod win {
use super::Error;
use std::{io, ptr};
use windows_sys::Win32::Foundation::{FALSE, HANDLE, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::Console::{
GetConsoleMode, GetConsoleScreenBufferInfo, GetStdHandle, ScrollConsoleScreenBufferW,
SetConsoleCursorPosition, SetConsoleMode, CHAR_INFO, CHAR_INFO_0, CONSOLE_MODE,
CONSOLE_SCREEN_BUFFER_INFO, COORD, ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT,
ENABLE_PROCESSED_INPUT, ENABLE_VIRTUAL_TERMINAL_PROCESSING, SMALL_RECT, STD_INPUT_HANDLE,
STD_OUTPUT_HANDLE,
};
#[cfg(feature = "windows-console")]
use windows_sys::Win32::System::Console::{
FillConsoleOutputAttribute, FillConsoleOutputCharacterW,
};
fn console_handle() -> Result<HANDLE, Error> {
match unsafe { GetStdHandle(STD_OUTPUT_HANDLE) } {
INVALID_HANDLE_VALUE => Err(io::Error::last_os_error().into()),
handle => Ok(handle),
}
}
fn console_input_handle() -> Result<HANDLE, Error> {
match unsafe { GetStdHandle(STD_INPUT_HANDLE) } {
INVALID_HANDLE_VALUE => Err(io::Error::last_os_error().into()),
handle => Ok(handle),
}
}
fn buffer_info(console: HANDLE) -> Result<CONSOLE_SCREEN_BUFFER_INFO, Error> {
let mut csbi: CONSOLE_SCREEN_BUFFER_INFO = unsafe { std::mem::zeroed() };
if unsafe { GetConsoleScreenBufferInfo(console, &mut csbi) } == FALSE {
return Err(io::Error::last_os_error().into());
}
Ok(csbi)
}
pub(crate) enum VtSupport {
NotAConsole,
NoVt,
Enabled(RestoreConsoleMode),
}
pub(crate) struct RestoreConsoleMode {
stdout: HANDLE,
mode: CONSOLE_MODE,
}
impl Drop for RestoreConsoleMode {
fn drop(&mut self) {
unsafe {
SetConsoleMode(self.stdout, self.mode);
}
}
}
pub(crate) fn vt() -> Result<VtSupport, Error> {
let stdout = console_handle()?;
let mut mode = 0;
if unsafe { GetConsoleMode(stdout, &mut mode) } == FALSE {
return Ok(VtSupport::NotAConsole);
}
if unsafe { SetConsoleMode(stdout, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING) } == FALSE {
return Ok(VtSupport::NoVt);
}
Ok(VtSupport::Enabled(RestoreConsoleMode { stdout, mode }))
}
pub(crate) fn vt_supported() -> bool {
matches!(vt(), Ok(VtSupport::Enabled(_)))
}
pub(crate) fn clear() -> Result<(), Error> {
let console = console_handle()?;
let csbi = buffer_info(console)?;
let rect = SMALL_RECT {
Left: 0,
Top: 0,
Right: csbi.dwSize.X,
Bottom: csbi.dwSize.Y,
};
let target = COORD {
X: 0,
Y: (0 - csbi.dwSize.Y) as i16,
};
let space = CHAR_INFO_0 {
AsciiChar: b' ' as i8,
};
let fill = CHAR_INFO {
Char: space,
Attributes: csbi.wAttributes,
};
if unsafe { ScrollConsoleScreenBufferW(console, &rect, ptr::null(), target, &fill) }
== FALSE
{
return Err(io::Error::last_os_error().into());
}
let mut cursor = csbi.dwCursorPosition;
cursor.X = 0;
cursor.Y = 0;
if unsafe { SetConsoleCursorPosition(console, cursor) } == FALSE {
return Err(io::Error::last_os_error().into());
}
Ok(())
}
#[cfg(feature = "windows-console")]
pub(crate) fn blank() -> Result<(), Error> {
let console = console_handle()?;
let csbi = buffer_info(console)?;
let buffer_size = csbi.dwSize.X * csbi.dwSize.Y;
let home_coord = COORD { X: 0, Y: 0 };
if FALSE
== unsafe {
FillConsoleOutputCharacterW(
console,
b' ' as u16,
u32::try_from(buffer_size).unwrap_or(0),
home_coord,
ptr::null_mut(),
)
} {
return Err(io::Error::last_os_error().into());
}
let csbi = buffer_info(console)?;
if FALSE
== unsafe {
FillConsoleOutputAttribute(
console,
csbi.wAttributes,
u32::try_from(buffer_size).unwrap_or(0),
home_coord,
ptr::null_mut(),
)
} {
return Err(io::Error::last_os_error().into());
}
if unsafe { SetConsoleCursorPosition(console, home_coord) } == FALSE {
return Err(io::Error::last_os_error().into());
}
Ok(())
}
const ENABLE_COOKED_MODE: CONSOLE_MODE =
ENABLE_ECHO_INPUT | ENABLE_LINE_INPUT | ENABLE_PROCESSED_INPUT;
pub(crate) fn cooked() -> Result<(), Error> {
let stdin = console_input_handle()?;
let mut mode = 0;
if unsafe { GetConsoleMode(stdin, &mut mode) } == FALSE {
return Err(io::Error::last_os_error().into());
}
mode |= ENABLE_COOKED_MODE;
if unsafe { SetConsoleMode(stdin, mode) } == FALSE {
return Err(io::Error::last_os_error().into());
}
Ok(())
}
}
#[cfg(not(unix))]
#[allow(clippy::unnecessary_wraps)]
mod unix {
use super::Error;
pub(crate) fn vt_cooked() -> Result<(), Error> {
Ok(())
}
pub(crate) fn vt_well_done() -> Result<(), Error> {
Ok(())
}
}
#[cfg(not(windows))]
#[allow(clippy::unnecessary_wraps)]
mod win {
use super::Error;
pub(crate) fn clear() -> Result<(), Error> {
Ok(())
}
#[cfg(feature = "windows-console")]
pub(crate) fn blank() -> Result<(), Error> {
Ok(())
}
pub(crate) fn cooked() -> Result<(), Error> {
Ok(())
}
pub(crate) fn vt_supported() -> bool {
false
}
}
#[derive(Eq, PartialEq, Clone, Debug)]
struct ResetScrollback<'a>(Cow<'a, [u8]>);
impl<'a> Capability<'a> for ResetScrollback<'a> {
#[inline]
fn name() -> &'static str {
"E3"
}
#[inline]
fn from(value: Option<&'a Value>) -> Option<Self> {
if let Some(Value::String(value)) = value {
Some(Self(Cow::Borrowed(value)))
} else {
None
}
}
#[inline]
fn into(self) -> Option<Value> {
Some(Value::String(match self.0 {
Cow::Borrowed(value) => value.into(),
Cow::Owned(value) => value,
}))
}
}
impl<'a, T: AsRef<&'a [u8]>> From<T> for ResetScrollback<'a> {
#[inline]
fn from(value: T) -> Self {
Self(Cow::Borrowed(value.as_ref()))
}
}
impl AsRef<[u8]> for ResetScrollback<'_> {
#[inline]
fn as_ref(&self) -> &[u8] {
&self.0
}
}
impl ResetScrollback<'_> {
#[inline]
fn expand_to(
&self,
output: &mut impl Write,
context: Option<&mut Context>,
) -> Result<(), terminfo::Error> {
self.as_ref().expand(
output,
&<[Parameter; 9]>::default(),
context.unwrap_or(&mut Default::default()),
)
}
}