summaryrefslogtreecommitdiff
path: root/crates/typst-cli
diff options
context:
space:
mode:
authorfrozolotl <44589151+frozolotl@users.noreply.github.com>2024-01-31 10:19:07 +0100
committerGitHub <noreply@github.com>2024-01-31 09:19:07 +0000
commit6999be9ab0d851d0b3b34bd311712c27887721f1 (patch)
treece2f9c71eee715a892a479fbd8c591fafb0e9cd7 /crates/typst-cli
parent51854ba4df0e941bd17acee499fdb3a7e43b31e0 (diff)
Use alternate screen and refactor terminal output. (#2665)
Diffstat (limited to 'crates/typst-cli')
-rw-r--r--crates/typst-cli/Cargo.toml1
-rw-r--r--crates/typst-cli/src/compile.rs14
-rw-r--r--crates/typst-cli/src/download.rs84
-rw-r--r--crates/typst-cli/src/main.rs52
-rw-r--r--crates/typst-cli/src/package.rs12
-rw-r--r--crates/typst-cli/src/terminal.rs162
-rw-r--r--crates/typst-cli/src/watch.rs53
7 files changed, 257 insertions, 121 deletions
diff --git a/crates/typst-cli/Cargo.toml b/crates/typst-cli/Cargo.toml
index d16175ed..c25e290f 100644
--- a/crates/typst-cli/Cargo.toml
+++ b/crates/typst-cli/Cargo.toml
@@ -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 }
diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs
index 2c80ce3f..f66553f6 100644
--- a/crates/typst-cli/src/compile.rs
+++ b/crates/typst-cli/src/compile.rs
@@ -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)?;
}
}
diff --git a/crates/typst-cli/src/download.rs b/crates/typst-cli/src/download.rs
index fc3d3f1e..bdf2aa46 100644
--- a/crates/typst-cli/src/download.rs
+++ b/crates/typst-cli/src/download.rs
@@ -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"
+}
diff --git a/crates/typst-cli/src/main.rs b/crates/typst-cli/src/main.rs
index 8917adc3..c7221f77 100644
--- a/crates/typst-cli/src/main.rs
+++ b/crates/typst-cli/src/main.rs
@@ -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,36 +63,21 @@ fn set_failed() {
EXIT.with(|cell| cell.set(ExitCode::FAILURE));
}
+/// 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 mut w = color_stream();
let styles = term::Styles::default();
- w.set_color(&styles.header_error)?;
- write!(w, "error")?;
+ let mut output = terminal::out();
+ output.set_color(&styles.header_error)?;
+ write!(output, "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")
+ output.reset()?;
+ writeln!(output, ": {msg}.")
}
#[cfg(not(feature = "self-update"))]
diff --git a/crates/typst-cli/src/package.rs b/crates/typst-cli/src/package.rs
index 247a045d..8141ad19 100644
--- a/crates/typst-cli/src/package.rs
+++ b/crates/typst-cli/src/package.rs
@@ -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}")
}
diff --git a/crates/typst-cli/src/terminal.rs b/crates/typst-cli/src/terminal.rs
new file mode 100644
index 00000000..f0c57b43
--- /dev/null
+++ b/crates/typst-cli/src/terminal.rs
@@ -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()
+ }
+}
diff --git a/crates/typst-cli/src/watch.rs b/crates/typst-cli/src/watch.rs
index ee3a8f5b..67276a3e 100644
--- a/crates/typst-cli/src/watch.rs
+++ b/crates/typst-cli/src/watch.rs
@@ -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 {