diff --git a/Cargo.lock b/Cargo.lock index 9ce43d74d..ecb8a7191 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 4ae79576b..0e1eb9382 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/typst-cli/Cargo.toml b/crates/typst-cli/Cargo.toml index d16175edf..c25e290f6 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 2c80ce3f6..f66553f6d 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 = Result; 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 fc3d3f1ee..bdf2aa464 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, start_time: Instant, last_print: Option, - displayed_charcount: Option, - 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 8917adc3d..c7221f775 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 = 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; diff --git a/crates/typst-cli/src/package.rs b/crates/typst-cli/src/package.rs index 247a045d8..8141ad191 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 { @@ -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 000000000..f0c57b431 --- /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 = 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 { + 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 ee3a8f5bc..67276a3e5 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 {