Use alternate screen and refactor terminal output. (#2665)

This commit is contained in:
frozolotl 2024-01-31 10:19:07 +01:00 committed by GitHub
parent 51854ba4df
commit 6999be9ab0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 282 additions and 123 deletions

22
Cargo.lock generated
View file

@ -495,6 +495,16 @@ dependencies = [
"memchr",
]
[[package]]
name = "ctrlc"
version = "3.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82e95fbd621905b854affdc67943b043a0fbb6ed7385fd5a25650d19a8a6cfdf"
dependencies = [
"nix",
"windows-sys 0.48.0",
]
[[package]]
name = "data-url"
version = "0.3.1"
@ -1383,6 +1393,17 @@ dependencies = [
"tempfile",
]
[[package]]
name = "nix"
version = "0.27.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053"
dependencies = [
"bitflags 2.4.1",
"cfg-if",
"libc",
]
[[package]]
name = "notify"
version = "6.1.1"
@ -2557,6 +2578,7 @@ dependencies = [
"clap_mangen",
"codespan-reporting",
"comemo",
"ctrlc",
"dirs",
"ecow",
"env_proxy",

View file

@ -36,6 +36,7 @@ ciborium = "0.2.1"
clap = { version = "4.4", features = ["derive", "env"] }
clap_complete = "4.2.1"
clap_mangen = "0.2.10"
ctrlc = "3.4.1"
codespan-reporting = "0.11"
comemo = { git = "https://github.com/typst/comemo", rev = "ddb3773" }
csv = "1"

View file

@ -30,6 +30,7 @@ chrono = { workspace = true }
clap = { workspace = true }
codespan-reporting = { workspace = true }
comemo = { workspace = true }
ctrlc = { workspace = true }
dirs = { workspace = true }
ecow = { workspace = true }
env_proxy = { workspace = true }

View file

@ -3,11 +3,10 @@ use std::path::{Path, PathBuf};
use chrono::{Datelike, Timelike};
use codespan_reporting::diagnostic::{Diagnostic, Label};
use codespan_reporting::term::{self, termcolor};
use codespan_reporting::term;
use ecow::{eco_format, EcoString};
use parking_lot::RwLock;
use rayon::iter::{IndexedParallelIterator, IntoParallelRefIterator, ParallelIterator};
use termcolor::{ColorChoice, StandardStream};
use typst::diag::{bail, At, Severity, SourceDiagnostic, StrResult};
use typst::eval::Tracer;
use typst::foundations::Datetime;
@ -21,7 +20,7 @@ use crate::args::{CompileCommand, DiagnosticFormat, OutputFormat};
use crate::timings::Timer;
use crate::watch::Status;
use crate::world::SystemWorld;
use crate::{color_stream, set_failed};
use crate::{set_failed, terminal};
type CodespanResult<T> = Result<T, CodespanError>;
type CodespanError = codespan_reporting::files::Error;
@ -313,11 +312,6 @@ pub fn print_diagnostics(
warnings: &[SourceDiagnostic],
diagnostic_format: DiagnosticFormat,
) -> Result<(), codespan_reporting::files::Error> {
let mut w = match diagnostic_format {
DiagnosticFormat::Human => color_stream(),
DiagnosticFormat::Short => StandardStream::stderr(ColorChoice::Never),
};
let mut config = term::Config { tab_width: 2, ..Default::default() };
if diagnostic_format == DiagnosticFormat::Short {
config.display_style = term::DisplayStyle::Short;
@ -338,7 +332,7 @@ pub fn print_diagnostics(
)
.with_labels(label(world, diagnostic.span).into_iter().collect());
term::emit(&mut w, &config, world, &diag)?;
term::emit(&mut terminal::out(), &config, world, &diag)?;
// Stacktrace-like helper diagnostics.
for point in &diagnostic.trace {
@ -347,7 +341,7 @@ pub fn print_diagnostics(
.with_message(message)
.with_labels(label(world, point.span).into_iter().collect());
term::emit(&mut w, &config, world, &help)?;
term::emit(&mut terminal::out(), &config, world, &help)?;
}
}

View file

@ -3,7 +3,7 @@
// https://github.com/rust-lang/rustup/blob/master/src/cli/download_tracker.rs
use std::collections::VecDeque;
use std::io::{self, ErrorKind, Read, Stderr, Write};
use std::io::{self, ErrorKind, Read, Write};
use std::sync::Arc;
use std::time::{Duration, Instant};
@ -11,6 +11,8 @@ use native_tls::{Certificate, TlsConnector};
use once_cell::sync::Lazy;
use ureq::Response;
use crate::terminal;
/// Keep track of this many download speed samples.
const SPEED_SAMPLES: usize = 5;
@ -72,8 +74,6 @@ struct RemoteReader {
downloaded_last_few_secs: VecDeque<usize>,
start_time: Instant,
last_print: Option<Instant>,
displayed_charcount: Option<usize>,
stderr: Stderr,
}
impl RemoteReader {
@ -94,8 +94,6 @@ impl RemoteReader {
downloaded_last_few_secs: VecDeque::with_capacity(SPEED_SAMPLES),
start_time: Instant::now(),
last_print: None,
displayed_charcount: None,
stderr: io::stderr(),
}
}
@ -146,66 +144,52 @@ impl RemoteReader {
self.downloaded_last_few_secs.push_front(self.downloaded_this_sec);
self.downloaded_this_sec = 0;
if let Some(n) = self.displayed_charcount {
self.erase_chars(n);
}
self.display();
let _ = write!(self.stderr, "\r");
terminal::out().clear_last_line()?;
self.display()?;
self.last_print = Some(Instant::now());
}
}
self.display();
let _ = writeln!(self.stderr);
self.display()?;
writeln!(&mut terminal::out())?;
Ok(data)
}
/// Compile and format several download statistics and make an attempt at
/// displaying them on standard error.
fn display(&mut self) {
fn display(&mut self) -> io::Result<()> {
let sum: usize = self.downloaded_last_few_secs.iter().sum();
let len = self.downloaded_last_few_secs.len();
let speed = if len > 0 { sum / len } else { self.content_len.unwrap_or(0) };
let total = as_time_unit(self.total_downloaded, false);
let speed_h = as_time_unit(speed, true);
let total_downloaded = as_bytes_unit(self.total_downloaded);
let speed_h = as_throughput_unit(speed);
let elapsed =
time_suffix(Instant::now().saturating_duration_since(self.start_time));
let output = match self.content_len {
match self.content_len {
Some(content_len) => {
let percent = (self.total_downloaded as f64 / content_len as f64) * 100.;
let remaining = content_len - self.total_downloaded;
format!(
"{} / {} ({:3.0} %) {} in {} ETA: {}",
total,
as_time_unit(content_len, false),
percent,
speed_h,
elapsed,
time_suffix(Duration::from_secs(if speed == 0 {
0
} else {
(remaining / speed) as u64
}))
)
let download_size = as_bytes_unit(content_len);
let eta = time_suffix(Duration::from_secs(if speed == 0 {
0
} else {
(remaining / speed) as u64
}));
writeln!(
&mut terminal::out(),
"{total_downloaded} / {download_size} ({percent:3.0} %) {speed_h} in {elapsed} ETA: {eta}",
)?;
}
None => format!("Total: {total} Speed: {speed_h} Elapsed: {elapsed}"),
None => writeln!(
&mut terminal::out(),
"Total downloaded: {total_downloaded} Speed: {speed_h} Elapsed: {elapsed}",
)?,
};
let _ = write!(self.stderr, "{output}");
self.displayed_charcount = Some(output.chars().count());
}
/// Erase each previously printed character and add a carriage return
/// character, clearing the line for the next `display()` update.
fn erase_chars(&mut self, count: usize) {
let _ = write!(self.stderr, "{}", " ".repeat(count));
let _ = write!(self.stderr, "\r");
Ok(())
}
}
@ -231,22 +215,24 @@ fn format_dhms(sec: u64) -> (u64, u8, u8, u8) {
/// Format a given size as a unit of time. Setting `include_suffix` to true
/// appends a '/s' (per second) suffix.
fn as_time_unit(size: usize, include_suffix: bool) -> String {
fn as_bytes_unit(size: usize) -> String {
const KI: f64 = 1024.0;
const MI: f64 = KI * KI;
const GI: f64 = KI * KI * KI;
let size = size as f64;
let suffix = if include_suffix { "/s" } else { "" };
if size >= GI {
format!("{:5.1} GiB{}", size / GI, suffix)
format!("{:5.1} GiB", size / GI)
} else if size >= MI {
format!("{:5.1} MiB{}", size / MI, suffix)
format!("{:5.1} MiB", size / MI)
} else if size >= KI {
format!("{:5.1} KiB{}", size / KI, suffix)
format!("{:5.1} KiB", size / KI)
} else {
format!("{size:3.0} B{suffix}")
format!("{size:3.0} B")
}
}
fn as_throughput_unit(size: usize) -> String {
as_bytes_unit(size) + "/s"
}

View file

@ -4,6 +4,7 @@ mod download;
mod fonts;
mod package;
mod query;
mod terminal;
mod timings;
#[cfg(feature = "self-update")]
mod update;
@ -11,13 +12,14 @@ mod watch;
mod world;
use std::cell::Cell;
use std::io::{self, IsTerminal, Write};
use std::io::{self, Write};
use std::process::ExitCode;
use clap::Parser;
use codespan_reporting::term::{self, termcolor};
use codespan_reporting::term;
use codespan_reporting::term::termcolor::WriteColor;
use ecow::eco_format;
use once_cell::sync::Lazy;
use termcolor::{ColorChoice, WriteColor};
use crate::args::{CliArguments, Command};
use crate::timings::Timer;
@ -33,6 +35,7 @@ static ARGS: Lazy<CliArguments> = Lazy::new(CliArguments::parse);
/// Entry point.
fn main() -> ExitCode {
let timer = Timer::new(&ARGS);
let res = match &ARGS.command {
Command::Compile(command) => crate::compile::compile(timer, command.clone()),
Command::Watch(command) => crate::watch::watch(timer, command.clone()),
@ -41,7 +44,13 @@ fn main() -> ExitCode {
Command::Update(command) => crate::update::update(command),
};
if let Err(msg) = res {
// Leave the alternate screen if it was opened. This operation is done here
// so that it is executed prior to printing the final error.
let res_leave = terminal::out()
.leave_alternate_screen()
.map_err(|err| eco_format!("failed to leave alternate screen ({err})"));
if let Err(msg) = res.or(res_leave) {
set_failed();
print_error(&msg).expect("failed to print error");
}
@ -54,38 +63,23 @@ fn set_failed() {
EXIT.with(|cell| cell.set(ExitCode::FAILURE));
}
/// Print an application-level error (independent from a source file).
fn print_error(msg: &str) -> io::Result<()> {
let mut w = color_stream();
let styles = term::Styles::default();
w.set_color(&styles.header_error)?;
write!(w, "error")?;
w.reset()?;
writeln!(w, ": {msg}.")
}
/// Get stderr with color support if desirable.
fn color_stream() -> termcolor::StandardStream {
termcolor::StandardStream::stderr(match ARGS.color {
clap::ColorChoice::Auto => {
if std::io::stderr().is_terminal() {
ColorChoice::Auto
} else {
ColorChoice::Never
}
}
clap::ColorChoice::Always => ColorChoice::Always,
clap::ColorChoice::Never => ColorChoice::Never,
})
}
/// Used by `args.rs`.
fn typst_version() -> &'static str {
env!("TYPST_VERSION")
}
/// Print an application-level error (independent from a source file).
fn print_error(msg: &str) -> io::Result<()> {
let styles = term::Styles::default();
let mut output = terminal::out();
output.set_color(&styles.header_error)?;
write!(output, "error")?;
output.reset()?;
writeln!(output, ": {msg}.")
}
#[cfg(not(feature = "self-update"))]
mod update {
use crate::args::UpdateCommand;

View file

@ -8,8 +8,8 @@ use termcolor::WriteColor;
use typst::diag::{PackageError, PackageResult};
use typst::syntax::PackageSpec;
use crate::color_stream;
use crate::download::download_with_progress;
use crate::terminal;
/// Make a package available in the on-disk cache.
pub fn prepare_package(spec: &PackageSpec) -> PackageResult<PathBuf> {
@ -69,12 +69,12 @@ fn download_package(spec: &PackageSpec, package_dir: &Path) -> PackageResult<()>
/// Print that a package downloading is happening.
fn print_downloading(spec: &PackageSpec) -> io::Result<()> {
let mut w = color_stream();
let styles = term::Styles::default();
w.set_color(&styles.header_help)?;
write!(w, "downloading")?;
let mut term_out = terminal::out();
term_out.set_color(&styles.header_help)?;
write!(term_out, "downloading")?;
w.reset()?;
writeln!(w, " {spec}")
term_out.reset()?;
writeln!(term_out, " {spec}")
}

View file

@ -0,0 +1,162 @@
use std::io::{self, IsTerminal, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use codespan_reporting::term::termcolor;
use ecow::eco_format;
use once_cell::sync::Lazy;
use termcolor::{ColorChoice, WriteColor};
use typst::diag::StrResult;
use crate::ARGS;
/// Returns a handle to the optionally colored terminal output.
pub fn out() -> TermOut {
static OUTPUT: Lazy<TermOutInner> = Lazy::new(TermOutInner::new);
TermOut { inner: &OUTPUT }
}
/// The stuff that has to be shared between instances of [`TermOut`].
struct TermOutInner {
active: AtomicBool,
stream: termcolor::StandardStream,
in_alternate_screen: AtomicBool,
}
impl TermOutInner {
fn new() -> Self {
let color_choice = match ARGS.color {
clap::ColorChoice::Auto if std::io::stderr().is_terminal() => {
ColorChoice::Auto
}
clap::ColorChoice::Always => ColorChoice::Always,
_ => ColorChoice::Never,
};
let stream = termcolor::StandardStream::stderr(color_choice);
TermOutInner {
active: AtomicBool::new(true),
stream,
in_alternate_screen: AtomicBool::new(false),
}
}
}
/// A utility that allows users to write colored terminal output.
/// If colors are not supported by the terminal, they are disabled.
/// This type also allows for deletion of previously written lines.
#[derive(Clone)]
pub struct TermOut {
inner: &'static TermOutInner,
}
impl TermOut {
/// Initialize a handler that listens for Ctrl-C signals.
/// This is used to exit the alternate screen that might have been opened.
pub fn init_exit_handler(&mut self) -> StrResult<()> {
/// The duration the application may keep running after an exit signal was received.
const MAX_TIME_TO_EXIT: Duration = Duration::from_millis(750);
// We can safely ignore the error as the only thing this handler would do
// is leave an alternate screen if none was opened; not very important.
let mut term_out = self.clone();
ctrlc::set_handler(move || {
term_out.inner.active.store(false, Ordering::Release);
// Wait for some time and if the application is still running, simply exit.
// Not exiting immediately potentially allows destructors to run and file writes
// to complete.
std::thread::sleep(MAX_TIME_TO_EXIT);
// Leave alternate screen only after the timeout has expired.
// This prevents console output intended only for within the alternate screen
// from showing up outside it.
// Remember that the alternate screen is also closed if the timeout is not reached,
// just from a different location in code.
let _ = term_out.leave_alternate_screen();
// Exit with the exit code standard for Ctrl-C exits[^1].
// There doesn't seem to be another standard exit code for Windows,
// so we just use the same one there.
// [^1]: https://tldp.org/LDP/abs/html/exitcodes.html
std::process::exit(128 + 2);
})
.map_err(|err| eco_format!("failed to initialize exit handler ({err})"))
}
/// Whether this program is still active and was not stopped by the Ctrl-C handler.
pub fn is_active(&self) -> bool {
self.inner.active.load(Ordering::Acquire)
}
/// Clears the entire screen.
pub fn clear_screen(&mut self) -> io::Result<()> {
// We don't want to clear anything that is not a TTY.
if self.inner.stream.supports_color() {
let mut stream = self.inner.stream.lock();
// Clear the screen and then move the cursor to the top left corner.
write!(stream, "\x1B[2J\x1B[1;1H")?;
stream.flush()?;
}
Ok(())
}
/// Clears the previously written line.
pub fn clear_last_line(&mut self) -> io::Result<()> {
// We don't want to clear anything that is not a TTY.
if self.inner.stream.supports_color() {
// First, move the cursor up `lines` lines.
// Then, clear everything between between the cursor to end of screen.
let mut stream = self.inner.stream.lock();
write!(stream, "\x1B[1F\x1B[0J")?;
stream.flush()?;
}
Ok(())
}
/// Enters the alternate screen if none was opened already.
pub fn enter_alternate_screen(&mut self) -> io::Result<()> {
if !self.inner.in_alternate_screen.load(Ordering::Acquire) {
let mut stream = self.inner.stream.lock();
write!(stream, "\x1B[?1049h")?;
stream.flush()?;
self.inner.in_alternate_screen.store(true, Ordering::Release);
}
Ok(())
}
/// Leaves the alternate screen if it is already open.
pub fn leave_alternate_screen(&mut self) -> io::Result<()> {
if self.inner.in_alternate_screen.load(Ordering::Acquire) {
let mut stream = self.inner.stream.lock();
write!(stream, "\x1B[?1049l")?;
stream.flush()?;
self.inner.in_alternate_screen.store(false, Ordering::Release);
}
Ok(())
}
}
impl Write for TermOut {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.inner.stream.lock().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.inner.stream.lock().flush()
}
}
impl WriteColor for TermOut {
fn supports_color(&self) -> bool {
self.inner.stream.supports_color()
}
fn set_color(&mut self, spec: &termcolor::ColorSpec) -> io::Result<()> {
self.inner.stream.lock().set_color(spec)
}
fn reset(&mut self) -> io::Result<()> {
self.inner.stream.lock().reset()
}
}

View file

@ -1,22 +1,28 @@
use std::collections::HashMap;
use std::io::{self, IsTerminal, Write};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use codespan_reporting::term::termcolor::WriteColor;
use codespan_reporting::term::{self, termcolor};
use ecow::eco_format;
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use same_file::is_same_file;
use termcolor::WriteColor;
use typst::diag::StrResult;
use crate::args::CompileCommand;
use crate::color_stream;
use crate::compile::compile_once;
use crate::terminal;
use crate::timings::Timer;
use crate::world::SystemWorld;
/// Execute a watching compilation command.
pub fn watch(mut timer: Timer, mut command: CompileCommand) -> StrResult<()> {
// Enter the alternate screen and handle Ctrl-C ourselves.
terminal::out().init_exit_handler()?;
terminal::out()
.enter_alternate_screen()
.map_err(|err| eco_format!("failed to enter alternate screen ({err})"))?;
// Create the world that serves sources, files, and fonts.
let mut world = SystemWorld::new(&command.common)?;
@ -35,13 +41,9 @@ pub fn watch(mut timer: Timer, mut command: CompileCommand) -> StrResult<()> {
// Handle events.
let timeout = std::time::Duration::from_millis(100);
let output = command.output();
loop {
while terminal::out().is_active() {
let mut recompile = false;
for event in rx
.recv()
.into_iter()
.chain(std::iter::from_fn(|| rx.recv_timeout(timeout).ok()))
{
if let Ok(event) = rx.recv_timeout(timeout) {
let event =
event.map_err(|err| eco_format!("failed to watch directory ({err})"))?;
@ -77,6 +79,7 @@ pub fn watch(mut timer: Timer, mut command: CompileCommand) -> StrResult<()> {
watch_dependencies(&mut world, &mut watcher, &mut watched)?;
}
}
Ok(())
}
/// Adjust the file watching. Watches all new dependencies and unwatches
@ -159,28 +162,24 @@ impl Status {
let timestamp = chrono::offset::Local::now().format("%H:%M:%S");
let color = self.color();
let mut w = color_stream();
if std::io::stderr().is_terminal() {
// Clear the terminal.
let esc = 27 as char;
write!(w, "{esc}[2J{esc}[1;1H")?;
}
let mut term_out = terminal::out();
term_out.clear_screen()?;
w.set_color(&color)?;
write!(w, "watching")?;
w.reset()?;
writeln!(w, " {}", command.common.input.display())?;
term_out.set_color(&color)?;
write!(term_out, "watching")?;
term_out.reset()?;
writeln!(term_out, " {}", command.common.input.display())?;
w.set_color(&color)?;
write!(w, "writing to")?;
w.reset()?;
writeln!(w, " {}", output.display())?;
term_out.set_color(&color)?;
write!(term_out, "writing to")?;
term_out.reset()?;
writeln!(term_out, " {}", output.display())?;
writeln!(w)?;
writeln!(w, "[{timestamp}] {}", self.message())?;
writeln!(w)?;
writeln!(term_out)?;
writeln!(term_out, "[{timestamp}] {}", self.message())?;
writeln!(term_out)?;
w.flush()
term_out.flush()
}
fn message(&self) -> String {