fastqc_rust/progress.rs
1//! Live terminal progress reporting, drawn in place:
2//!
3//! ```text
4//! FastQC-Rust v1.1.0
5//!
6//! Failed to process notes.txt: ID line didn't start with '@' at line 1
7//! sample_1.fastq.gz ⠹ ━━━━━━━━━━━━━━━━━━━━╸━━━━━━━ 72% 2.1M reads 4s
8//! sample_2.fastq.gz ✔ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% 3.0M reads 6s
9//!
10//! ┌───────────────────────────────────┬────────────────┬────────────────┐
11//! │ Measure │ sample_1.fast… │ sample_2.fast… │
12//! ├───────────────────────────────────┼────────────────┼────────────────┤
13//! │ File type │ Conventional … │ Conventional … │
14//! │ ... │ ... │ ... │
15//! └───────────────────────────────────┴────────────────┴────────────────┘
16//! Complete. Analysed 2 files in 00:06
17//! ```
18//!
19//! The bars, the table and the closing summary are a redrawn region pinned to
20//! the bottom of the terminal. The name and version, and any warning or error,
21//! are ordinary log lines that scroll up above it.
22//!
23//! The display adapts to the size of the run:
24//!
25//! * **1-10 files** get a progress bar each, in the order given on the command
26//! line, showing that file's own progress.
27//! * **More than 10 files** collapse to a single bar counting completed files,
28//! because a screenful of bars is worse than no bars at all.
29//! * **A live statistics table** is drawn underneath whenever the terminal is
30//! wide enough for every column to be readable — so a wide terminal gets it
31//! for more files, and a narrow one does without. Its columns are the files
32//! and its rows the Basic Statistics measures from the top of the HTML
33//! report. Cells start as `-` and fill in as the analysis runs; the final
34//! values are exactly those in the report, because both are rendered from the
35//! same counters (see
36//! [`crate::modules::basic_stats::BasicStatsCounters::rows`]).
37//!
38//! # Animation and colour
39//!
40//! These are two independent switches, each auto-detected and each overridable
41//! through the environment — there are no command-line flags for them.
42//! `--quiet` beats both and says nothing but warnings and errors.
43//!
44//! **Animation** (`FASTQC_PROGRESS=auto|always|never`) — the default `auto`
45//! draws the display only for an interactive stderr. When stderr is a pipe, a
46//! log file or a workflow engine's capture, or when `TERM` says the terminal
47//! cannot handle a redrawn region (`dumb`, or unset on Unix), a redrawn region
48//! would be noise or outright corruption, so the reporter degrades to one plain
49//! line per file at start and finish. `always` draws it regardless — for
50//! recording a demo, or feeding a consumer that re-renders the stream — by
51//! handing indicatif the terminal directly instead of its self-hiding stderr
52//! draw target, sizing itself from `COLUMNS`/`LINES` since there is no terminal
53//! to measure. `never` always takes the plain path.
54//!
55//! **Colour** follows [`console::colors_enabled_stderr`], which implements the
56//! usual conventions: the [clicolors spec](https://bixense.com/clicolors/)
57//! `CLICOLOR=0` disables colour and `CLICOLOR_FORCE=1` forces it on even for a
58//! pipe, and `TERM=dumb` disables it. A non-empty `NO_COLOR` disables it over
59//! all of those, which console does not do on its own.
60//! indicatif's template styling reads the same function, so one signal covers
61//! this module's styling and the bars alike.
62//!
63//! Because the two are independent: colour off still draws the bars, just
64//! without escape codes; and the plain fallback still colours its lines when
65//! colour is forced on, which is what a CI log viewer wants — it renders escape
66//! sequences happily while not being a terminal.
67//!
68//! # Log lines
69//!
70//! Anything printed while the display is up has to go through [`log_line`], or
71//! [`ProgressReporter::error`]. Writing to stderr directly would land in the
72//! middle of the display and be erased by the next frame. A warning raised from
73//! an inner analysis loop pairs [`log_line`] with an [`OncePerRun`] latch, so
74//! the run says it once instead of once per base.
75//!
76//! Those lines are written *above* the redrawn region and scroll up as ordinary
77//! terminal output, which is what rich, tqdm, indicatif, cargo and Nextflow all
78//! do. The log is the permanent record and has to be able to grow without
79//! bound, and the terminal's scrollback is the only place unbounded output can
80//! go. The version banner is printed by [`ProgressPlan::new`] before the run
81//! does anything else, so everything the run has to say appears beneath it in
82//! the order it happened.
83//!
84//! The exception is the closing `Complete. Analysed N files in mm:ss`, which is
85//! the last line of the redrawn region so that it always lands below the bars
86//! and the table. `--quiet` suppresses it along with everything else.
87
88use std::sync::atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering};
89use std::sync::{Arc, Mutex};
90use std::thread::JoinHandle;
91use std::time::{Duration, Instant};
92
93use console::{style, Term};
94use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle, TermLike};
95
96use crate::modules::basic_stats::{BasicStatsCounters, LiveStats};
97
98/// Environment variable selecting the progress display. `FASTQC_`-prefixed so
99/// that it cannot collide with anything in the environment it is read from.
100pub const PROGRESS_ENV: &str = "FASTQC_PROGRESS";
101
102/// A tri-state switch for behaviour that is normally auto-detected.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
104pub enum When {
105 /// Decide from the environment (default).
106 #[default]
107 Auto,
108 /// Force on, whatever the environment looks like.
109 Always,
110 /// Force off.
111 Never,
112}
113
114impl When {
115 /// Parse a `FASTQC_PROGRESS`-style value. Unrecognised values fall back to
116 /// `Auto` rather than failing a run over a display preference.
117 fn parse(value: &str) -> Self {
118 match value.trim().to_ascii_lowercase().as_str() {
119 "always" | "force" | "1" | "yes" | "true" | "on" => When::Always,
120 "never" | "none" | "0" | "no" | "false" | "off" => When::Never,
121 _ => When::Auto,
122 }
123 }
124
125 fn from_env() -> Self {
126 std::env::var(PROGRESS_ENV)
127 .map(|v| Self::parse(&v))
128 .unwrap_or_default()
129 }
130}
131
132/// Above this many files, per-file bars are replaced by a single bar counting
133/// completed files.
134const MAX_FILE_BARS: usize = 10;
135
136/// Widest a value column grows before the extra room is left as margin.
137const MAX_VALUE_WIDTH: usize = 28;
138
139/// Narrowest a statistics-table value column may be and still be worth reading.
140/// Whether the table is shown at all is decided by whether every column can
141/// have at least this much room (see [`layout`]), so a wide terminal shows
142/// the table for more files and a narrow one drops it sooner.
143const MIN_VALUE_WIDTH: usize = 16;
144
145/// How often the live statistics table is re-rendered.
146const TABLE_REFRESH: Duration = Duration::from_millis(150);
147
148/// Redraw rate for a forced display, matching indicatif's own default for its
149/// stderr draw target.
150const DRAW_RATE_HZ: u8 = 20;
151
152/// Spinner animation period for the running bars.
153const SPINNER_TICK: Duration = Duration::from_millis(90);
154
155/// Fallback terminal size when it cannot be measured and the environment does
156/// not say (`FASTQC_PROGRESS=always` over a pipe, for instance).
157const DEFAULT_TERM_WIDTH: usize = 100;
158const DEFAULT_TERM_HEIGHT: u16 = 24;
159
160/// Longest a file name may be before it is truncated in a bar label.
161const MAX_NAME_WIDTH: usize = 30;
162
163/// Progress is tracked in permille rather than percent so the bars move
164/// smoothly rather than in 1% steps.
165const SCALE: u64 = 1000;
166
167/// Where log lines go while a display is on screen.
168///
169/// Lines are written above the redrawn region and scroll up as ordinary
170/// terminal output, which is the convention for this kind of display (rich,
171/// tqdm, indicatif, cargo, Nextflow all do the same). The log is the permanent
172/// record and has to be able to grow without bound; the terminal's scrollback
173/// is the only place unbounded output can go, and it is below the display that
174/// there is no room.
175struct LogSink {
176 multi: MultiProgress,
177 /// `\r\n` when the display has been forced onto something that is not a
178 /// terminal. A tty driver rewrites `\n` as `\r\n` on the way out (ONLCR);
179 /// nothing does that for a pipe, so a bare newline would leave the cursor
180 /// parked in this line's column and the next frame would start there.
181 line_ending: &'static str,
182 /// First line of the redrawn region, blank once anything has been logged so
183 /// the bars are not flush against the messages above them. It renders as
184 /// nothing while empty, which is what keeps it out of the way on the usual
185 /// run that logs nothing at all.
186 padding: ProgressBar,
187}
188
189impl LogSink {
190 fn print(&self, message: &str) {
191 self.padding.set_message(" ");
192 // Erase the region, write the line as ordinary scrollback, and redraw
193 // the display beneath it, all under indicatif's draw lock so no other
194 // thread's frame can land between the erase and the line.
195 self.multi
196 .suspend(|| eprint!("{}{}", message, self.line_ending));
197 }
198}
199
200/// The log sink of the display currently drawing to stderr, if there is one.
201///
202/// Code deep in the analysis (a module noticing bad data, a reader hitting an
203/// odd record) has no handle on the reporter, but its warnings must not be
204/// written straight to stderr while a redrawn region is on screen — they would
205/// land in the middle of the bars and be overwritten by the next frame.
206static ACTIVE_LOG: Mutex<Option<Arc<LogSink>>> = Mutex::new(None);
207
208/// Emit a line above the progress display, wherever it is called from. Falls
209/// back to plain stderr when no display is active.
210pub fn log_line(message: &str) {
211 let active = ACTIVE_LOG.lock().unwrap_or_else(|e| e.into_inner()).clone();
212 match active {
213 Some(sink) => sink.print(message),
214 None => eprintln!("{}", message),
215 }
216}
217
218/// Which run is in progress. Bumped by [`ProgressPlan::new`], and compared
219/// against by [`OncePerRun`] to know whether it has already spoken.
220static RUN: AtomicU64 = AtomicU64::new(1);
221
222/// A latch for a warning raised from an innermost analysis loop, where the same
223/// message can be produced millions of times — once per bad base, once per
224/// unreadable read.
225///
226/// Declared as a `static` beside the code that raises the warning, so asking
227/// whether to speak is one relaxed atomic: no formatting, no allocation and no
228/// lock on the overwhelmingly common repeat. That matters because these sit in
229/// the hot loop — building the message unconditionally and then discarding it
230/// cost ~26% of the analysis on a file whose every base tripped the warning.
231///
232/// The latch is per *run*, not per process: it resets when the next
233/// [`ProgressPlan`] is created, so an embedder driving the library twice hears
234/// the warning both times. Within a run it fires once however many files are
235/// being analysed in parallel, which is the point — the warning is about the
236/// data, and one copy of it is the useful amount.
237pub struct OncePerRun(AtomicU64);
238
239impl Default for OncePerRun {
240 fn default() -> Self {
241 Self::new()
242 }
243}
244
245impl OncePerRun {
246 pub const fn new() -> Self {
247 OncePerRun(AtomicU64::new(0))
248 }
249
250 /// True for exactly one caller per run. Racing threads all swap in the
251 /// current run, and only the one that displaced an older value speaks.
252 /// The plain load first keeps repeats off the cache line's write path.
253 pub fn should_say(&self) -> bool {
254 let run = RUN.load(Ordering::Relaxed);
255 self.0.load(Ordering::Relaxed) != run && self.0.swap(run, Ordering::Relaxed) != run
256 }
257
258 /// [`log_line`] the warning `message` builds, the first time this run.
259 /// Out of line so the hot loop it is raised from stays tight.
260 #[cold]
261 #[inline(never)]
262 pub fn log(&self, message: impl FnOnce() -> String) {
263 if self.should_say() {
264 log_line(&message());
265 }
266 }
267}
268
269/// The terminal progress display for a whole run.
270///
271/// Cheap to share across the rayon workers: every method is `&self` and
272/// no-ops when the display is disabled.
273pub struct ProgressReporter {
274 mode: Mode,
275 /// When the run started, for the closing summary.
276 started: Instant,
277}
278
279enum Mode {
280 /// `--quiet`: say nothing at all.
281 Silent,
282 /// No redrawn display: one plain line per file at start and finish. Still
283 /// colour-aware, because a CI log viewer renders escape sequences happily
284 /// even though it is not a terminal.
285 Plain,
286 /// A terminal: live bars, and optionally a live statistics table.
287 Live(Box<Live>),
288}
289
290struct Live {
291 bars: Bars,
292 table: Option<Arc<Table>>,
293 /// The closing summary, drawn as the last line of the region so it always
294 /// lands below the bars and the table. Empty until the run finishes.
295 summary: ProgressBar,
296 /// Blank line kept at the bottom of the redrawn region.
297 trailer: ProgressBar,
298 /// Where log lines go: above the display, as ordinary scrollback.
299 log: Arc<LogSink>,
300 ticker: Mutex<Option<JoinHandle<()>>>,
301 stop: Arc<AtomicBool>,
302}
303
304/// Cheap to clone: the ticker thread holds its own handle.
305#[derive(Clone)]
306enum Bars {
307 /// One bar per file, indexed by file group.
308 PerFile(Arc<Vec<FileBar>>),
309 /// A single bar counting completed files.
310 Aggregate(ProgressBar),
311}
312
313/// One file's bar, plus whether its analysis has actually begun.
314///
315/// The flag matters because indicatif starts a bar's clock when the bar is
316/// *created*, and all the bars are created together before any file is opened.
317/// A run with more files than parallel slots would otherwise show a queued file
318/// counting up the time it spent waiting, and then report that as how long it
319/// took. So the clock is reset when the file starts, and until then the bar is
320/// left alone entirely — not even ticked, so its spinner does not animate as
321/// though something were happening.
322struct FileBar {
323 bar: ProgressBar,
324 started: AtomicBool,
325}
326
327impl Bars {
328 /// The bars whose spinner should be advancing: the ones whose file is
329 /// actually being worked on. A file still queued behind another has nothing
330 /// happening, and a spinning spinner would say otherwise.
331 fn spinners(&self) -> Box<dyn Iterator<Item = &ProgressBar> + '_> {
332 match self {
333 Bars::PerFile(bars) => Box::new(
334 bars.iter()
335 .filter(|file| file.started.load(Ordering::Relaxed))
336 .map(|file| &file.bar),
337 ),
338 Bars::Aggregate(bar) => Box::new(std::iter::once(bar)),
339 }
340 }
341}
342
343/// Which of the three display modes a run should use.
344///
345/// Split out from [`ProgressReporter::new`] so the decision can be tested
346/// without a terminal or environment fiddling.
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348enum ModeChoice {
349 Silent,
350 Plain,
351 /// `bypass_detection` when the display is going somewhere indicatif would
352 /// refuse to draw to, which is the one thing `Live::new` needs to know: it
353 /// then has to drive the terminal itself rather than through indicatif's
354 /// self-hiding stderr target. That is a fact about the output, not about
355 /// what was asked for — `FASTQC_PROGRESS=always` in a real terminal is an
356 /// ordinary live display.
357 Live {
358 bypass_detection: bool,
359 },
360}
361
362/// Decide how to report progress.
363///
364/// `--quiet` wins over everything: it is the stronger statement, so
365/// `--quiet FASTQC_PROGRESS=always` is still silent. Otherwise
366/// `FASTQC_PROGRESS` decides if it was given explicitly, and only `auto`
367/// consults the environment.
368///
369/// Auto-detection needs a terminal the display can redraw in place: stderr must
370/// be a tty *and* `TERM` must describe a terminal that can do more than accept
371/// plain text. `dumb` (and, on Unix, an unset `TERM`) fails that second test —
372/// indicatif hides its bars in exactly that case, so without the check a run
373/// would print a banner and then go completely silent.
374fn choose_mode(
375 quiet: bool,
376 progress: When,
377 stderr_is_terminal: bool,
378 dumb_terminal: bool,
379) -> ModeChoice {
380 if quiet {
381 return ModeChoice::Silent;
382 }
383 // The same condition indicatif's stderr draw target hides itself on, so
384 // when it holds there is nothing to bypass and `always` costs nothing.
385 let drawable = stderr_is_terminal && !dumb_terminal;
386 match progress {
387 When::Always => ModeChoice::Live {
388 bypass_detection: !drawable,
389 },
390 When::Never => ModeChoice::Plain,
391 When::Auto if drawable => ModeChoice::Live {
392 bypass_detection: false,
393 },
394 When::Auto => ModeChoice::Plain,
395 }
396}
397
398/// The mode this process would use, from `--quiet` and the environment.
399///
400/// Called once, by [`ProgressPlan::new`], which stores the answer so both
401/// phases of startup act on the same decision. Split from [`choose_mode`] so
402/// that the rule itself can be tested without a terminal or the environment.
403fn current_choice(quiet: bool) -> ModeChoice {
404 choose_mode(
405 quiet,
406 When::from_env(),
407 // console's own tty probe, not `std::io::IsTerminal`: indicatif decides
408 // whether to hide its bars with `console::Term::is_term`, and the two
409 // disagree on an MSYS pty, where console recognises a terminal that
410 // `IsTerminal` does not.
411 Term::stderr().is_term(),
412 console::is_dumb(),
413 )
414}
415
416/// A decided-but-not-yet-drawn display, and the first half of starting a run.
417///
418/// The display cannot be built until the input files have been validated and
419/// grouped, because it needs one bar per group — but the banner has to be
420/// printed *before* that work, or the messages validation emits land above it
421/// and the "everything the run says appears beneath the banner" property is
422/// quietly false. So the decision and the banner happen here, at the very top
423/// of the run, and [`start`](Self::start) turns the plan into a reporter once
424/// the names are known.
425pub struct ProgressPlan {
426 choice: ModeChoice,
427 started: Instant,
428}
429
430impl ProgressPlan {
431 /// Decide how this run will report, and announce it. Call this first:
432 /// the clock it starts is the one the closing summary reports.
433 pub fn new(quiet: bool) -> Self {
434 // console lets CLICOLOR_FORCE override NO_COLOR; the NO_COLOR spec
435 // says it wins over everything.
436 if std::env::var_os("NO_COLOR").is_some_and(|value| !value.is_empty()) {
437 console::set_colors_enabled_stderr(false);
438 }
439 let choice = current_choice(quiet);
440 // "Once per run" for [`OncePerRun`] means once per plan.
441 RUN.fetch_add(1, Ordering::Relaxed);
442 if let ModeChoice::Live { bypass_detection } = choice {
443 // Coloured after the logo: the name in its blue, the `-Rust`
444 // suffix in its red, the version dim. Written straight to stderr
445 // because nothing has been drawn yet — there is no region to clear
446 // and no padding to add.
447 let line_ending = line_ending(bypass_detection);
448 eprint!(
449 "{}{} {}{line_ending}{line_ending}",
450 paint("FastQC", |s| s.color256(LOGO_BLUE).bold()),
451 paint("-Rust", |s| s.color256(LOGO_RED).bold()),
452 paint(&format!("v{}", crate::RUST_VERSION), |s| s.dim()),
453 );
454 }
455 ProgressPlan {
456 choice,
457 started: Instant::now(),
458 }
459 }
460
461 /// Draw the display for `names` (the file group display names, in
462 /// command-line order).
463 pub fn start(self, names: &[String]) -> ProgressReporter {
464 let mode = match self.choice {
465 ModeChoice::Silent => Mode::Silent,
466 ModeChoice::Plain => Mode::Plain,
467 ModeChoice::Live { bypass_detection } => {
468 Mode::Live(Box::new(Live::new(names, bypass_detection)))
469 }
470 };
471 ProgressReporter {
472 mode,
473 started: self.started,
474 }
475 }
476}
477
478/// A tty driver rewrites `\n` as `\r\n` on the way out (ONLCR); nothing does
479/// that for a pipe, so a bare newline would leave the cursor parked in this
480/// line's column and the next frame would start there.
481fn line_ending(bypass_detection: bool) -> &'static str {
482 if bypass_detection {
483 "\r\n"
484 } else {
485 "\n"
486 }
487}
488
489impl ProgressReporter {
490 /// A reporter that displays nothing. Used by tests and by callers of the
491 /// library API that drive the analysis themselves.
492 pub fn hidden() -> Self {
493 ProgressReporter {
494 mode: Mode::Silent,
495 started: Instant::now(),
496 }
497 }
498
499 /// A handle scoped to one file group, for the code that actually runs the
500 /// analysis.
501 pub fn file(&self, index: usize) -> FileProgress<'_> {
502 FileProgress {
503 reporter: self,
504 index,
505 }
506 }
507
508 /// Print an error line above the display. Shown even under `--quiet`.
509 ///
510 /// Routed through [`log_line`] rather than matched on the mode: the two
511 /// would say the same thing, since a log sink is registered exactly while
512 /// a display is up.
513 pub fn error(&self, message: &str) {
514 log_line(&paint_error(message));
515 }
516
517 /// Tear the display down once every file is done, leaving the final state
518 /// on screen, and report what the run got through.
519 ///
520 /// `analysed` is the number of file groups that completed successfully;
521 /// anything that failed has already been reported as an error line.
522 /// `failed` turns the closing "Complete." red.
523 pub fn finish(&self, analysed: usize, failed: bool) {
524 let summary = format!(
525 "Analysed {} {} in {}",
526 analysed,
527 if analysed == 1 { "file" } else { "files" },
528 clock_duration(self.started.elapsed()),
529 );
530 let complete = paint("Complete.", |s| {
531 if failed { s.red() } else { s.green() }.bold()
532 });
533 match &self.mode {
534 // --quiet stays quiet: the run said nothing, so it ends saying
535 // nothing.
536 Mode::Silent => {}
537 Mode::Plain => eprintln!("{} {}", complete, summary),
538 Mode::Live(live) => {
539 // The last line of the redrawn region, so it lands below the
540 // bars and the table rather than scrolling past above them.
541 live.summary
542 .set_message(format!("{} {}", complete, paint(&summary, |s| s.dim())));
543 live.finish(analysed);
544 }
545 }
546 }
547}
548
549/// A progress handle for a single file group.
550#[derive(Clone, Copy)]
551pub struct FileProgress<'a> {
552 reporter: &'a ProgressReporter,
553 index: usize,
554}
555
556impl FileProgress<'_> {
557 /// The live statistics sink for this file, if the table is being shown.
558 /// Attached to the file's BasicStats module so it can publish partial
559 /// results as it works.
560 pub fn live_stats(&self) -> Option<Arc<LiveStats>> {
561 match &self.reporter.mode {
562 Mode::Live(live) => live
563 .table
564 .as_ref()
565 .and_then(|t| t.columns.get(self.index))
566 .map(|c| Arc::clone(&c.live)),
567 _ => None,
568 }
569 }
570
571 /// Announce that analysis of this file has begun.
572 pub fn start(&self, name: &str) {
573 match &self.reporter.mode {
574 Mode::Silent => {}
575 Mode::Plain => eprintln!("Started analysis of {}", paint(name, |s| s.bold())),
576 Mode::Live(live) => live.start(self.index),
577 }
578 }
579
580 /// Report how far through the file the reader is. `reads` is the number of
581 /// records handed to the modules so far.
582 ///
583 /// `percent` is a closure rather than a value because
584 /// `SequenceFile::percent_complete` costs a seek on the input file, and
585 /// there is often no bar for it to move — under `--quiet`, without a
586 /// terminal, or when many files share a single completion-counting bar.
587 /// Leaving that decision here keeps the reader from having to ask.
588 pub fn update(&self, reads: u64, percent: impl FnOnce() -> f64) {
589 if let Mode::Live(live) = &self.reporter.mode {
590 live.progress(self.index, reads, percent);
591 }
592 }
593
594 /// Note that the file has been read and the run has moved on to another
595 /// phase (rendering charts, writing the report).
596 pub fn stage(&self, stage: &str) {
597 if let Mode::Live(live) = &self.reporter.mode {
598 live.stage(self.index, stage);
599 }
600 }
601
602 /// Mark the file finished.
603 pub fn finish(&self, name: &str, reads: u64) {
604 match &self.reporter.mode {
605 Mode::Silent => {}
606 Mode::Plain => eprintln!("Analysis complete for {}", paint(name, |s| s.bold())),
607 Mode::Live(live) => live.finish_file(self.index, reads),
608 }
609 }
610
611 /// Mark the file failed.
612 pub fn fail(&self) {
613 if let Mode::Live(live) = &self.reporter.mode {
614 live.fail_file(self.index);
615 }
616 }
617}
618
619impl Live {
620 /// `bypass_detection` means stderr is not something indicatif will draw a
621 /// redrawn region to, and the display has been forced on anyway.
622 fn new(names: &[String], bypass_detection: bool) -> Self {
623 // The default stderr draw target hides itself when stderr is not an
624 // interactive terminal. `FASTQC_PROGRESS=always` asks for the display
625 // anyway, so bypass that check by handing indicatif the terminal
626 // directly: `term_like` performs no detection of its own. It also
627 // applies no rate limiting unless one is given, which would redraw the
628 // whole display on every single position update, so pass the same
629 // refresh rate `ProgressDrawTarget::stderr` uses.
630 let multi = if bypass_detection {
631 MultiProgress::with_draw_target(ProgressDrawTarget::term_like_with_hz(
632 Box::new(ForcedTerm::new()),
633 DRAW_RATE_HZ,
634 ))
635 } else {
636 MultiProgress::new()
637 };
638
639 let log = Arc::new(LogSink {
640 multi: multi.clone(),
641 line_ending: line_ending(bypass_detection),
642 // First line of the region, so the blank it grows lands between the
643 // messages and the bars.
644 padding: static_line(&multi),
645 });
646 *ACTIVE_LOG.lock().unwrap_or_else(|e| e.into_inner()) = Some(Arc::clone(&log));
647
648 let label_width = names
649 .iter()
650 .map(|n| console::measure_text_width(n))
651 .max()
652 .unwrap_or(0)
653 .min(MAX_NAME_WIDTH)
654 .max("FastQ files".len());
655
656 let bars = if names.len() > MAX_FILE_BARS {
657 // Too many files for a bar each: count completed files instead.
658 let bar = multi.add(ProgressBar::new(names.len() as u64));
659 bar.set_style(aggregate_style());
660 bar.set_prefix(pad_cell(&paint("FastQ files", |s| s.bold()), label_width));
661 Bars::Aggregate(bar)
662 } else {
663 let running = running_style();
664 let bars = names
665 .iter()
666 .map(|name| {
667 let bar = multi.add(ProgressBar::new(SCALE));
668 bar.set_style(running.clone());
669 bar.set_prefix(pad_cell(&paint(name, |s| s.bold()), label_width));
670 bar.set_message("waiting");
671 bar.tick();
672 FileBar {
673 bar,
674 started: AtomicBool::new(false),
675 }
676 })
677 .collect();
678 Bars::PerFile(Arc::new(bars))
679 };
680
681 // The table decides for itself, on every refresh, whether the terminal
682 // is currently wide enough to give every column room to be read, so it
683 // appears and disappears as the window is resized. It is built here
684 // whenever there is anything at all to tabulate.
685 let table = (!names.is_empty()).then(|| Arc::new(Table::new(&multi, names)));
686
687 // The closing summary is the last line of the region, so it always
688 // lands below the bars and the table however the run went. It renders
689 // as nothing until it has a message.
690 let summary = static_line(&multi);
691
692 // A blank line below everything, so the shell prompt does not land
693 // flush against the display.
694 let trailer = static_line(&multi);
695 trailer.set_message(" ");
696
697 let live = Live {
698 bars,
699 table,
700 summary,
701 trailer,
702 log,
703 ticker: Mutex::new(None),
704 stop: Arc::new(AtomicBool::new(false)),
705 };
706 live.start_ticker();
707 live
708 }
709
710 /// Animate the display from a single background thread: the analysis threads
711 /// only ever publish counters and positions, they never render.
712 ///
713 /// indicatif's own `enable_steady_tick` spawns a thread per bar, which for
714 /// a ten-file run means ten threads competing for the same draw lock purely
715 /// to advance a spinner. One thread does both jobs.
716 fn start_ticker(&self) {
717 let table = self.table.as_ref().map(Arc::clone);
718 let bars = self.bars.clone();
719 let stop = Arc::clone(&self.stop);
720 let handle = std::thread::Builder::new()
721 .name("fastqc-progress".into())
722 .spawn(move || {
723 // The table is re-rendered more slowly than the spinners are
724 // advanced, on its own deadline rather than a second thread.
725 let mut due = Instant::now();
726 while !stop.load(Ordering::Relaxed) {
727 for bar in bars.spinners() {
728 if !bar.is_finished() {
729 bar.tick();
730 }
731 }
732 let now = Instant::now();
733 if now >= due {
734 due = now + TABLE_REFRESH;
735 if let Some(table) = &table {
736 table.refresh();
737 }
738 }
739 std::thread::sleep(SPINNER_TICK);
740 }
741 // One last render so the table shows the finished values.
742 if let Some(table) = &table {
743 table.refresh();
744 }
745 });
746 if let Ok(handle) = handle {
747 *self.ticker.lock().unwrap_or_else(|e| e.into_inner()) = Some(handle);
748 }
749 }
750
751 fn bar(&self, index: usize) -> Option<&ProgressBar> {
752 match &self.bars {
753 Bars::PerFile(bars) => bars.get(index).map(|file| &file.bar),
754 Bars::Aggregate(_) => None,
755 }
756 }
757
758 fn start(&self, index: usize) {
759 let Bars::PerFile(bars) = &self.bars else {
760 return;
761 };
762 let Some(file) = bars.get(index) else {
763 return;
764 };
765 file.started.store(true, Ordering::Relaxed);
766 // The bar was created with every other bar, before any file was opened,
767 // and indicatif has been counting since. Restart the clock so the time
768 // this bar reports is the time this file took.
769 file.bar.reset_elapsed();
770 file.bar.set_message("reading");
771 }
772
773 /// Both halves of this are guarded by a comparison against the bar's
774 /// current state, because both `set_position` and `set_message` reach
775 /// indicatif's redraw — which takes the global draw lock and re-formats the
776 /// line — while `position` and `message` only take that one bar's lock.
777 /// This runs once per thousand reads on every file at once, and most calls
778 /// change nothing: the bar has only [`SCALE`] distinct positions, and
779 /// `human_count` is coarse enough that past a million reads the same label
780 /// is produced for a hundred consecutive updates.
781 fn progress(&self, index: usize, reads: u64, percent: impl FnOnce() -> f64) {
782 let Some(bar) = self.bar(index) else {
783 return;
784 };
785 let position = (percent().clamp(0.0, 100.0) / 100.0 * SCALE as f64) as u64;
786 if bar.position() != position {
787 bar.set_position(position);
788 }
789 let label = format!("{} reads", human_count(reads));
790 if bar.message() != label {
791 bar.set_message(label);
792 }
793 }
794
795 fn stage(&self, index: usize, stage: &str) {
796 if let Some(bar) = self.bar(index) {
797 bar.set_position(SCALE);
798 bar.set_message(stage.to_string());
799 }
800 }
801
802 /// Record how a file ended so the table heading can follow its bar.
803 fn set_file_state(&self, index: usize, state: FileState) {
804 if let Some(column) = self.table.as_ref().and_then(|t| t.columns.get(index)) {
805 column.state.store(state as u8, Ordering::Relaxed);
806 }
807 }
808
809 fn finish_file(&self, index: usize, reads: u64) {
810 self.set_file_state(index, FileState::Analysed);
811 match &self.bars {
812 Bars::PerFile(bars) => {
813 if let Some(file) = bars.get(index) {
814 file.bar.set_style(done_style());
815 file.bar.set_position(SCALE);
816 file.bar
817 .set_message(format!("{} reads", human_count(reads)));
818 file.bar.finish();
819 }
820 }
821 Bars::Aggregate(bar) => bar.inc(1),
822 }
823 }
824
825 fn fail_file(&self, index: usize) {
826 self.set_file_state(index, FileState::Failed);
827 match &self.bars {
828 Bars::PerFile(bars) => {
829 if let Some(file) = bars.get(index) {
830 file.bar.set_style(failed_style());
831 file.bar.set_message("failed");
832 file.bar.abandon();
833 }
834 }
835 Bars::Aggregate(bar) => bar.inc(1),
836 }
837 }
838
839 fn finish(&self, analysed: usize) {
840 if let Bars::Aggregate(bar) = &self.bars {
841 bar.set_style(if bar.length() == Some(analysed as u64) {
842 aggregate_done_style()
843 } else {
844 aggregate_failed_style()
845 });
846 bar.finish();
847 }
848 self.shut_down();
849 // indicatif erases any bar that is still unfinished when it is dropped,
850 // so the static lines have to be explicitly finished for the completed
851 // display to survive the end of the run.
852 if let Some(table) = &self.table {
853 table.finish();
854 }
855 self.log.padding.finish();
856 self.summary.finish();
857 self.trailer.finish();
858 }
859
860 /// Stop the redraw thread and detach this display from the log sink.
861 /// Idempotent, so [`Drop`] can repeat it after [`Live::finish`].
862 fn shut_down(&self) {
863 self.stop.store(true, Ordering::Relaxed);
864 if let Some(handle) = self.ticker.lock().unwrap_or_else(|e| e.into_inner()).take() {
865 let _ = handle.join();
866 }
867 let mut active = ACTIVE_LOG.lock().unwrap_or_else(|e| e.into_inner());
868 if active
869 .as_ref()
870 .is_some_and(|sink| Arc::ptr_eq(sink, &self.log))
871 {
872 *active = None;
873 }
874 }
875}
876
877/// A run that unwinds before [`Live::finish`] must not leave the ticker
878/// redrawing a dead display, or log lines routed into it.
879impl Drop for Live {
880 fn drop(&mut self) {
881 self.shut_down();
882 }
883}
884
885/// Add a line of static text to the redrawn region. Implemented as a progress
886/// bar that renders nothing but its message, which is how indicatif keeps a
887/// block of text pinned below the bars.
888fn static_line(multi: &MultiProgress) -> ProgressBar {
889 let line = multi.add(ProgressBar::new(0));
890 line.set_style(ProgressStyle::with_template("{msg}").expect("static template"));
891 line.tick();
892 line
893}
894
895/// A stderr terminal that reports a usable size even when stderr is not a tty.
896///
897/// `console::Term` writes its cursor-movement and clear-line escapes
898/// unconditionally, so it drives the display fine over a pipe; what it cannot
899/// do is measure a pipe, and it falls back to a hardcoded 80 columns. That
900/// would leave `{wide_bar}` sized differently from the statistics table, which
901/// measures itself. Both go through this type instead, so they agree — and
902/// `COLUMNS`/`LINES` give a forced run (a recording, a CI job) a way to say how
903/// wide the output should be.
904#[derive(Debug)]
905struct ForcedTerm {
906 inner: Term,
907 width: u16,
908 height: u16,
909}
910
911/// How wide the display may draw, right now.
912///
913/// Measured on every table refresh rather than once at startup, so a window
914/// resized mid-run is followed rather than ignored. `COLUMNS` covers the case
915/// where stderr cannot be measured because it is not a terminal at all
916/// (`FASTQC_PROGRESS=always` over a pipe).
917fn measured_term_width() -> usize {
918 Term::stderr()
919 .size_checked()
920 .map(|(_, cols)| cols)
921 .or_else(|| env_dimension("COLUMNS"))
922 .unwrap_or(DEFAULT_TERM_WIDTH as u16) as usize
923}
924
925impl ForcedTerm {
926 fn new() -> Self {
927 // Buffered, like the `Term` behind indicatif's own stderr draw target.
928 // It matters: indicatif emits a frame as a run of writes and flushes at
929 // the end, and with an unbuffered terminal a concurrent draw can land
930 // in the middle of one, running two bar lines onto the same row.
931 let inner = Term::buffered_stderr();
932 let measured = inner.size_checked();
933 ForcedTerm {
934 width: measured_term_width() as u16,
935 height: measured
936 .map(|(rows, _)| rows)
937 .or_else(|| env_dimension("LINES"))
938 .unwrap_or(DEFAULT_TERM_HEIGHT),
939 inner,
940 }
941 }
942
943 /// `ESC [ n <op>`, the cursor-movement form. A zero count is a no-op
944 /// rather than an escape, matching what console would emit.
945 fn escape(&self, n: usize, op: char) -> std::io::Result<()> {
946 if n == 0 {
947 return Ok(());
948 }
949 self.inner.write_str(&format!("\x1b[{n}{op}"))
950 }
951}
952
953fn env_dimension(name: &str) -> Option<u16> {
954 std::env::var(name)
955 .ok()?
956 .parse::<u16>()
957 .ok()
958 .filter(|n| *n > 0)
959}
960
961impl TermLike for ForcedTerm {
962 fn width(&self) -> u16 {
963 self.width
964 }
965
966 fn height(&self) -> u16 {
967 self.height
968 }
969
970 // Cursor movement is written as ANSI rather than delegated to `Term`.
971 // console drives a real Windows console through the Win32 API, which
972 // silently does nothing when the handle is a pipe — so delegating would
973 // draw every frame and erase none, leaving one long concatenation. This
974 // type is only used when the display has been forced onto something that
975 // is not a terminal, where the consumer is a recorder or a log viewer that
976 // interprets escapes, so emitting them is exactly right. On Unix these are
977 // the same bytes `Term` would have written.
978 fn move_cursor_up(&self, n: usize) -> std::io::Result<()> {
979 self.escape(n, 'A')
980 }
981
982 fn move_cursor_down(&self, n: usize) -> std::io::Result<()> {
983 self.escape(n, 'B')
984 }
985
986 fn move_cursor_right(&self, n: usize) -> std::io::Result<()> {
987 self.escape(n, 'C')
988 }
989
990 fn move_cursor_left(&self, n: usize) -> std::io::Result<()> {
991 self.escape(n, 'D')
992 }
993
994 fn write_line(&self, s: &str) -> std::io::Result<()> {
995 self.inner.write_line(s)
996 }
997
998 fn write_str(&self, s: &str) -> std::io::Result<()> {
999 self.inner.write_str(s)
1000 }
1001
1002 fn clear_line(&self) -> std::io::Result<()> {
1003 self.inner.write_str("\r\x1b[2K")
1004 }
1005
1006 fn flush(&self) -> std::io::Result<()> {
1007 self.inner.flush()
1008 }
1009}
1010
1011/// The live Basic Statistics table shown underneath the progress bars.
1012///
1013/// The whole table is a *single* zero-length progress bar whose message spans
1014/// several lines — indicatif splits a message on newlines and accounts for
1015/// every line. One bar rather than one per row matters: a refresh is then a
1016/// single message update and so a single redraw, instead of a dozen redraws of
1017/// the entire display several times a second, which churns the terminal and
1018/// races with anything trying to print above it.
1019struct Table {
1020 line: ProgressBar,
1021 columns: Vec<Column>,
1022 /// The file names, kept so the geometry can be rebuilt at a new width.
1023 names: Vec<String>,
1024 state: Mutex<TableState>,
1025}
1026
1027struct TableState {
1028 /// Terminal width [`Geometry`] was built for. A run whose window is resized
1029 /// rebuilds at the new width rather than drawing a table cut to the old
1030 /// one; `{wide_bar}` above it already reflows on its own.
1031 width: usize,
1032 /// `None` when the terminal is too narrow for the table to be worth
1033 /// drawing, which is also how it disappears and comes back as the window is
1034 /// resized across the threshold.
1035 geometry: Option<Geometry>,
1036 /// The last frame rendered, so an unchanged one can be dropped rather than
1037 /// pushed through indicatif and out to the terminal again.
1038 last: String,
1039}
1040
1041/// Everything about the table that depends on how wide it may be, rendered once
1042/// per width rather than once per frame: the three borders, the styled vertical
1043/// rule, the heading and measure label cells, and each file's heading in each
1044/// of its three states.
1045struct Geometry {
1046 value_width: usize,
1047 top: String,
1048 divider: String,
1049 bottom: String,
1050 pipe: String,
1051 heading_label: String,
1052 /// One per measure, in report order — so this also fixes the row count.
1053 row_labels: Vec<String>,
1054 /// Per column, the heading pre-rendered in each state indexed by
1055 /// [`FileState`]: the colour is the only thing that varies, and re-styling
1056 /// it per frame would be pure waste.
1057 headings: Vec<[String; 3]>,
1058}
1059
1060struct Column {
1061 live: Arc<LiveStats>,
1062 /// Drives the heading colour, kept in step with the file's bar.
1063 state: AtomicU8,
1064}
1065
1066/// How a file is doing, for colouring its column heading the same way its
1067/// progress bar is coloured. The discriminant indexes [`Column::headings`].
1068#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1069enum FileState {
1070 /// Waiting or being read.
1071 Running = 0,
1072 Analysed = 1,
1073 Failed = 2,
1074}
1075
1076impl Geometry {
1077 /// Lay a table of `names` out for a terminal of `term_width`, or `None`
1078 /// when it is too narrow to be worth drawing — [`layout`] decides both.
1079 fn new(names: &[String], term_width: usize) -> Option<Self> {
1080 let (label_width, value_width) = layout(names.len(), term_width)?;
1081
1082 let rule = |left: char, mid: char, right: char| {
1083 let mut s = String::from(" ");
1084 s.push(left);
1085 s.push_str(&"─".repeat(label_width + 2));
1086 for _ in names {
1087 s.push(mid);
1088 s.push_str(&"─".repeat(value_width + 2));
1089 }
1090 s.push(right);
1091 paint(&s, |st| st.dim())
1092 };
1093
1094 Some(Geometry {
1095 value_width,
1096 top: rule('┌', '┬', '┐'),
1097 divider: rule('├', '┼', '┤'),
1098 bottom: rule('└', '┴', '┘'),
1099 pipe: paint("│", |s| s.dim()),
1100 heading_label: pad_cell(&paint("Measure", |s| s.dim()), label_width),
1101 row_labels: BasicStatsCounters::MEASURES
1102 .iter()
1103 .map(|m| pad_cell(m, label_width))
1104 .collect(),
1105 headings: names
1106 .iter()
1107 // The heading tracks the file's bar: accent while it is
1108 // running, green once analysed, red if it failed.
1109 .map(|name| {
1110 [
1111 pad_cell(&paint(name, |s| s.cyan().bold()), value_width),
1112 pad_cell(&paint(name, |s| s.green().bold()), value_width),
1113 pad_cell(&paint(name, |s| s.red().bold()), value_width),
1114 ]
1115 })
1116 .collect(),
1117 })
1118 }
1119
1120 /// A content line, from cells that are already styled and padded.
1121 fn row<'a>(&self, label_cell: &str, cells: impl Iterator<Item = &'a str>) -> String {
1122 let mut s = String::from(" ");
1123 s.push_str(&self.pipe);
1124 s.push(' ');
1125 s.push_str(label_cell);
1126 s.push(' ');
1127 for cell in cells {
1128 s.push_str(&self.pipe);
1129 s.push(' ');
1130 s.push_str(cell);
1131 s.push(' ');
1132 }
1133 s.push_str(&self.pipe);
1134 s
1135 }
1136}
1137
1138impl Table {
1139 fn new(multi: &MultiProgress, names: &[String]) -> Self {
1140 let columns = names
1141 .iter()
1142 .map(|_| Column {
1143 live: Arc::new(LiveStats::new()),
1144 state: AtomicU8::new(FileState::Running as u8),
1145 })
1146 .collect::<Vec<_>>();
1147
1148 let width = measured_term_width();
1149 let table = Table {
1150 line: static_line(multi),
1151 columns,
1152 names: names.to_vec(),
1153 state: Mutex::new(TableState {
1154 width,
1155 geometry: Geometry::new(names, width),
1156 last: String::new(),
1157 }),
1158 };
1159 table.refresh();
1160 table
1161 }
1162
1163 /// Mark the table finished so it is not erased when the progress bars
1164 /// behind it are dropped at the end of the run.
1165 fn finish(&self) {
1166 self.line.finish();
1167 }
1168
1169 /// Re-render the table from the latest published counters.
1170 ///
1171 /// Only the value cells are built here; everything that depends on the
1172 /// width alone lives in [`Geometry`] and is rebuilt only when the terminal
1173 /// is resized. The result is compared against the last frame and dropped if
1174 /// identical, which is the common case — columns only change every few
1175 /// thousand reads, and nothing changes at all while the reports are being
1176 /// written.
1177 fn refresh(&self) {
1178 let mut state = self.state.lock().unwrap_or_else(|e| e.into_inner());
1179
1180 let width = measured_term_width();
1181 if width != state.width {
1182 state.width = width;
1183 state.geometry = Geometry::new(&self.names, width);
1184 }
1185
1186 let Some(geometry) = &state.geometry else {
1187 // Too narrow for a readable table. Rendering nothing rather than
1188 // something cut off, and indicatif drops the line entirely.
1189 if !state.last.is_empty() {
1190 state.last = String::new();
1191 self.line.set_message("");
1192 }
1193 return;
1194 };
1195
1196 let mut out: Vec<String> = Vec::with_capacity(geometry.row_labels.len() + 5);
1197 // A single space rather than an empty string: indicatif skips lines
1198 // that render to nothing, and the spacer is wanted.
1199 out.push(" ".to_string());
1200 out.push(geometry.top.clone());
1201 out.push(geometry.row(
1202 &geometry.heading_label,
1203 self.columns.iter().enumerate().map(|(index, column)| {
1204 let file_state = column.state.load(Ordering::Relaxed) as usize;
1205 let headings = &geometry.headings[index];
1206 headings.get(file_state).unwrap_or(&headings[0]).as_str()
1207 }),
1208 ));
1209 out.push(geometry.divider.clone());
1210
1211 // A column that has not published yet shows "-" everywhere, so an idle
1212 // file reads as idle rather than as a file of zero reads.
1213 let values: Vec<Vec<String>> = self
1214 .columns
1215 .iter()
1216 .map(|column| {
1217 let snapshot = column.live.snapshot();
1218 column.live.request();
1219 match snapshot {
1220 None => vec!["-".to_string(); geometry.row_labels.len()],
1221 Some(counters) => counters.rows().into_iter().map(|(_, v)| v).collect(),
1222 }
1223 })
1224 .collect();
1225
1226 // Row labels and ordering come straight from the report's own table.
1227 for (index, label) in geometry.row_labels.iter().enumerate() {
1228 let cells: Vec<String> = values
1229 .iter()
1230 .map(|column| pad_cell(&paint(&column[index], |s| s.white()), geometry.value_width))
1231 .collect();
1232 out.push(geometry.row(label, cells.iter().map(String::as_str)));
1233 }
1234 out.push(geometry.bottom.clone());
1235
1236 let rendered = out.join("\n");
1237 if state.last == rendered {
1238 return;
1239 }
1240 // One update, one redraw.
1241 self.line.set_message(rendered.clone());
1242 state.last = rendered;
1243 }
1244}
1245
1246/// Terminal columns consumed by a table of `columns` files that are not cell
1247/// content: two of indentation, a border between and either side of every
1248/// cell, and a space either side of each.
1249fn table_overhead(columns: usize) -> usize {
1250 2 + (columns + 2) + 2 * (columns + 1)
1251}
1252
1253/// The `(label_width, value_width)` of a table for `columns` files at this
1254/// terminal width, or `None` when it is not worth drawing — which is when the
1255/// measure column cannot have its natural width or a value column would be
1256/// narrower than [`MIN_VALUE_WIDTH`].
1257///
1258/// This is the single place the geometry is decided; returning `None` is also
1259/// how the display decides not to show the table at all.
1260fn layout(columns: usize, term_width: usize) -> Option<(usize, usize)> {
1261 if columns == 0 {
1262 return None;
1263 }
1264 let label_width = BasicStatsCounters::MEASURES
1265 .iter()
1266 .map(|m| console::measure_text_width(m))
1267 .max()
1268 .unwrap_or(8);
1269 let available = term_width
1270 .saturating_sub(table_overhead(columns))
1271 .checked_sub(label_width)?;
1272
1273 let value_width = (available / columns).min(MAX_VALUE_WIDTH);
1274 (value_width >= MIN_VALUE_WIDTH).then_some((label_width, value_width))
1275}
1276
1277/// Fit a cell to exactly `width` columns, padding it out or truncating it with
1278/// an ellipsis. `console::pad_str` measures with `measure_text_width`, which
1279/// skips ANSI escapes, so a styled cell lines up with a plain one and is cut
1280/// without losing its trailing reset.
1281fn pad_cell(text: &str, width: usize) -> String {
1282 console::pad_str(text, width, console::Alignment::Left, Some("…")).into_owned()
1283}
1284
1285/// Style an error line red for stderr.
1286fn paint_error(text: &str) -> String {
1287 paint(text, |s| s.red())
1288}
1289
1290/// Style text for stderr.
1291///
1292/// `for_stderr` makes console resolve the escape codes against
1293/// `colors_enabled_stderr()` when the value is rendered, which is the same
1294/// signal indicatif's template styling uses — so there is nothing for this
1295/// module to decide or thread around.
1296fn paint(
1297 text: &str,
1298 apply: impl FnOnce(console::StyledObject<&str>) -> console::StyledObject<&str>,
1299) -> String {
1300 apply(style(text).for_stderr()).to_string()
1301}
1302
1303/// Template key rendering the elapsed time compactly and dimmed, shared by
1304/// every bar style so the last column always looks the same.
1305fn elapsed_key(
1306) -> impl Fn(&indicatif::ProgressState, &mut dyn std::fmt::Write) + Clone + Send + Sync + 'static {
1307 move |state: &indicatif::ProgressState, w: &mut dyn std::fmt::Write| {
1308 let _ = write!(
1309 w,
1310 "{}",
1311 paint(&short_duration(state.elapsed()), |s| s.dim())
1312 );
1313 }
1314}
1315
1316/// The FastQC logo's two colours, as the nearest xterm-256 entries.
1317///
1318/// Taken from the dark-background variant of the logo
1319/// (`docs/public/images/fastqc_logo_darkbg.svg`, `#659BFF` and `#AE3939`)
1320/// rather than the light-background one, whose navy `#000080` all but
1321/// disappears against a dark terminal. These mid-tones read on either.
1322///
1323/// Both are the closest entries in the 6x6x6 cube by CIELAB distance —
1324/// `#5f87ff` and `#d75f5f`. 256-colour rather than truecolor so that the one
1325/// code path works on every terminal; console emits 24-bit escapes without
1326/// checking whether the terminal can render them.
1327///
1328/// Public so the test that checks the banner is actually painted in them can
1329/// name them rather than restate the escape sequences.
1330pub const LOGO_BLUE: u8 = 69;
1331pub const LOGO_RED: u8 = 167;
1332
1333/// Bar characters chosen to match the heavy-line look of Python's `rich`.
1334const PROGRESS_CHARS: &str = "━╸━";
1335
1336/// Spinner frames. Inert for the styles whose template has no `{spinner}`.
1337const TICK_CHARS: &str = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ ";
1338
1339/// Build a bar style. The display's styles differ only in the marker before
1340/// the bar, the bar's colour, and the field between the bar and the elapsed
1341/// time; everything else — the column layout, the bar characters and the
1342/// elapsed-time key — is shared, so that a change to the layout is made once.
1343///
1344/// `marker` and `middle` are substituted into the template, and `format!` does
1345/// not re-scan a substituted value for braces, so both can carry placeholders
1346/// of their own.
1347fn bar_style(marker: &str, color: &str, middle: &str) -> ProgressStyle {
1348 ProgressStyle::with_template(&format!(
1349 " {{prefix}} {marker} {{wide_bar:.{color}/238}} {middle} {{elapsed:>5}}"
1350 ))
1351 .expect("static template")
1352 .progress_chars(PROGRESS_CHARS)
1353 .tick_chars(TICK_CHARS)
1354 .with_key("elapsed", elapsed_key())
1355}
1356
1357/// The per-file fields: percentage, then a short status or read count.
1358const FILE_FIELDS: &str = "{percent:>3}% {msg:<12}";
1359/// The aggregate bar counts files rather than tracking one.
1360const AGGREGATE_FIELDS: &str = "{pos}/{len} files";
1361
1362fn running_style() -> ProgressStyle {
1363 bar_style("{spinner:.cyan}", "cyan", FILE_FIELDS)
1364}
1365
1366fn done_style() -> ProgressStyle {
1367 bar_style(&paint("✔", |s| s.green().bold()), "green", FILE_FIELDS)
1368}
1369
1370fn failed_style() -> ProgressStyle {
1371 // Same column layout as the running and finished styles so a failed file
1372 // does not knock the other bars out of alignment. The bar is left at
1373 // whatever fraction of the file had been read when the error hit.
1374 bar_style(
1375 &paint("✘", |s| s.red().bold()),
1376 "red",
1377 &format!("{{percent:>3}}% {}", paint("{msg:<12}", |s| s.red())),
1378 )
1379}
1380
1381fn aggregate_style() -> ProgressStyle {
1382 bar_style("{spinner:.cyan}", "cyan", AGGREGATE_FIELDS)
1383}
1384
1385fn aggregate_done_style() -> ProgressStyle {
1386 bar_style(&paint("✔", |s| s.green().bold()), "green", AGGREGATE_FIELDS)
1387}
1388
1389fn aggregate_failed_style() -> ProgressStyle {
1390 bar_style(&paint("✘", |s| s.red().bold()), "red", AGGREGATE_FIELDS)
1391}
1392
1393/// Wall-clock elapsed time for the closing summary: `mm:ss`, widening to
1394/// `hh:mm:ss` past an hour rather than letting the minutes run past 59.
1395fn clock_duration(d: Duration) -> String {
1396 let secs = d.as_secs();
1397 if secs < 3600 {
1398 format!("{:02}:{:02}", secs / 60, secs % 60)
1399 } else {
1400 format!("{}:{:02}:{:02}", secs / 3600, (secs % 3600) / 60, secs % 60)
1401 }
1402}
1403
1404/// Compact elapsed time: `4.2s`, `1m12s`, `1h04m`.
1405fn short_duration(d: Duration) -> String {
1406 // Rounded before choosing the unit, so 59.96s reads `1m00s`, not `60.0s`.
1407 let tenths = (d.as_secs_f64() * 10.0).round() as u64;
1408 if tenths < 600 {
1409 return format!("{}.{}s", tenths / 10, tenths % 10);
1410 }
1411 let secs = d.as_secs().max(60);
1412 if secs < 3600 {
1413 format!("{}m{:02}s", secs / 60, secs % 60)
1414 } else {
1415 format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60)
1416 }
1417}
1418
1419/// Abbreviate a read count for display: `812`, `12.3k`, `3.0M`.
1420///
1421/// The thresholds are set where the *rounded* value would tip over rather than
1422/// at the round number, so a count just short of the next unit reads as `1.0M`
1423/// and not `1000.0k`.
1424fn human_count(n: u64) -> String {
1425 if n >= 999_950_000 {
1426 format!("{:.1}B", n as f64 / 1e9)
1427 } else if n >= 999_950 {
1428 format!("{:.1}M", n as f64 / 1e6)
1429 } else if n >= 1_000 {
1430 format!("{:.1}k", n as f64 / 1e3)
1431 } else {
1432 n.to_string()
1433 }
1434}
1435
1436#[cfg(test)]
1437mod tests {
1438 use super::*;
1439
1440 #[test]
1441 fn test_human_count() {
1442 assert_eq!(human_count(0), "0");
1443 assert_eq!(human_count(999), "999");
1444 assert_eq!(human_count(1_500), "1.5k");
1445 assert_eq!(human_count(3_000_000), "3.0M");
1446 assert_eq!(human_count(2_500_000_000), "2.5B");
1447
1448 // Just short of the next unit: the count must roll over rather than
1449 // print a mantissa that has rounded past the unit it is labelled with.
1450 assert_eq!(human_count(999_999), "1.0M");
1451 assert_eq!(human_count(999_999_999), "1.0B");
1452 // ...and just below the rollover it stays put.
1453 assert_eq!(human_count(999_949), "999.9k");
1454 assert_eq!(human_count(999_949_999), "999.9M");
1455 }
1456
1457 #[test]
1458 fn test_short_duration() {
1459 assert_eq!(short_duration(Duration::from_millis(4200)), "4.2s");
1460 assert_eq!(short_duration(Duration::from_millis(59_960)), "1m00s");
1461 assert_eq!(short_duration(Duration::from_secs(72)), "1m12s");
1462 assert_eq!(short_duration(Duration::from_secs(3840)), "1h04m");
1463 }
1464
1465 #[test]
1466 fn test_when_parse() {
1467 for on in [
1468 "always", "ALWAYS", " always ", "force", "1", "yes", "true", "on",
1469 ] {
1470 assert_eq!(When::parse(on), When::Always, "{on:?}");
1471 }
1472 for off in ["never", "Never", "none", "0", "no", "false", "off"] {
1473 assert_eq!(When::parse(off), When::Never, "{off:?}");
1474 }
1475 // Anything unrecognised falls back to detection rather than failing the
1476 // run over a display preference.
1477 for other in ["", "auto", "maybe", "yes please"] {
1478 assert_eq!(When::parse(other), When::Auto, "{other:?}");
1479 }
1480 }
1481
1482 /// Auto-detection: the live display needs both a tty and a terminal that
1483 /// can be redrawn.
1484 #[test]
1485 fn test_choose_mode_auto() {
1486 let auto = When::Auto;
1487 // is_terminal, dumb
1488 assert_eq!(
1489 choose_mode(false, auto, true, false),
1490 ModeChoice::Live {
1491 bypass_detection: false
1492 }
1493 );
1494 // Piped or redirected stderr: plain lines, never bars.
1495 assert_eq!(choose_mode(false, auto, false, false), ModeChoice::Plain);
1496 // A tty that cannot redraw (TERM=dumb, or unset on Unix): indicatif
1497 // would hide the bars, so fall back rather than going silent.
1498 assert_eq!(choose_mode(false, auto, true, true), ModeChoice::Plain);
1499 assert_eq!(choose_mode(false, auto, false, true), ModeChoice::Plain);
1500 }
1501
1502 /// `FASTQC_PROGRESS=always|never` overrides the detection in both
1503 /// directions. `always` bypasses indicatif's self-hiding draw target only
1504 /// where that target would in fact hide — driving the terminal by hand on a
1505 /// terminal that works would be a downgrade, not a force.
1506 #[test]
1507 fn test_choose_mode_forced() {
1508 // is_terminal, dumb, and whether the display has to be driven by hand.
1509 // Spelled out rather than recomputed from the rule, so that getting the
1510 // rule wrong fails the test instead of being copied into it.
1511 for (is_terminal, dumb, bypass_detection) in [
1512 (true, false, false),
1513 (true, true, true),
1514 (false, false, true),
1515 (false, true, true),
1516 ] {
1517 assert_eq!(
1518 choose_mode(false, When::Always, is_terminal, dumb),
1519 ModeChoice::Live { bypass_detection },
1520 "always must draw the display (tty={is_terminal}, dumb={dumb})"
1521 );
1522 assert_eq!(
1523 choose_mode(false, When::Never, is_terminal, dumb),
1524 ModeChoice::Plain,
1525 "never must not draw the display (tty={is_terminal}, dumb={dumb})"
1526 );
1527 }
1528 }
1529
1530 /// `--quiet` is the stronger statement and beats an explicit `always`.
1531 #[test]
1532 fn test_quiet_beats_forced_progress() {
1533 for progress in [When::Auto, When::Always, When::Never] {
1534 for &is_terminal in &[true, false] {
1535 assert_eq!(
1536 choose_mode(true, progress, is_terminal, false),
1537 ModeChoice::Silent,
1538 "--quiet must win over {progress:?}"
1539 );
1540 }
1541 }
1542 }
1543
1544 /// With no display active, a log line still reaches stderr rather than
1545 /// being swallowed.
1546 #[test]
1547 fn test_log_line_without_a_display() {
1548 assert!(ACTIVE_LOG
1549 .lock()
1550 .unwrap_or_else(|e| e.into_inner())
1551 .is_none());
1552 log_line("no display active, so this goes straight to stderr");
1553 }
1554
1555 /// Whenever the table is shown, it must fit inside the terminal, with the
1556 /// measure column at its natural width and every value column readable.
1557 #[test]
1558 fn test_a_shown_table_renders_inside_the_terminal() {
1559 let natural_label = BasicStatsCounters::MEASURES
1560 .iter()
1561 .map(|m| console::measure_text_width(m))
1562 .max()
1563 .unwrap();
1564 for term_width in [40usize, 60, 72, 80, 100, 120, 160, 200, 400] {
1565 for columns in 1..=12 {
1566 let Some((label_width, value_width)) = layout(columns, term_width) else {
1567 continue;
1568 };
1569 let total = table_overhead(columns) + label_width + value_width * columns;
1570 assert!(
1571 total <= term_width,
1572 "shown table of {columns} columns overflows {term_width} cols (needs {total})"
1573 );
1574 assert!(
1575 value_width >= MIN_VALUE_WIDTH,
1576 "value column below the readable minimum at {term_width} cols"
1577 );
1578 assert_eq!(
1579 label_width, natural_label,
1580 "measure column was squeezed at {term_width} cols"
1581 );
1582 }
1583 }
1584 }
1585
1586 /// The decision is made on width, not on how many files there are: a wider
1587 /// terminal earns more columns, a narrow one loses the table entirely.
1588 #[test]
1589 fn test_table_visibility_follows_terminal_width() {
1590 let fits = |columns, width| layout(columns, width).is_some();
1591
1592 // Nothing to tabulate.
1593 assert!(!fits(0, 200));
1594
1595 // 80 columns is enough for up to three files, not four.
1596 assert!(fits(1, 80));
1597 assert!(fits(3, 80));
1598 assert!(!fits(4, 80));
1599
1600 // Widening the terminal brings more columns into range, and the
1601 // threshold only ever moves one way.
1602 for columns in 1..=10 {
1603 // The first width that fits. Asserting that narrower ones do not
1604 // would be vacuous — `find` returns the smallest by definition.
1605 // What has to hold is the other direction: every wider terminal
1606 // fits too, so the table never blinks out again as the window
1607 // grows.
1608 let threshold = (1..600)
1609 .find(|w| fits(columns, *w))
1610 .expect("some width is wide enough");
1611 assert!(
1612 (threshold..600).all(|w| fits(columns, w)),
1613 "{columns} columns: fits at {threshold} but not at every wider width"
1614 );
1615 // More files always need at least as much room.
1616 if columns > 1 {
1617 let narrower = (1..600).find(|w| fits(columns - 1, *w)).unwrap();
1618 assert!(narrower < threshold);
1619 }
1620 }
1621
1622 // A 24-column terminal is never wide enough.
1623 assert!(!fits(1, 24));
1624 }
1625
1626 /// A cell always occupies exactly its column, whether it had to be padded
1627 /// out or cut down, and styling it does not change that: widths are
1628 /// measured in display columns, so the escapes do not count.
1629 #[test]
1630 fn test_pad_cell_fits_the_column_around_ansi() {
1631 let styled = style("abc").red().force_styling(true).to_string();
1632 let padded = pad_cell(&styled, 6);
1633 assert_eq!(console::measure_text_width(&padded), 6);
1634 assert!(padded.starts_with(&styled));
1635
1636 let long = style("abcdefghij").red().force_styling(true).to_string();
1637 let cut = pad_cell(&long, 6);
1638 assert_eq!(console::measure_text_width(&cut), 6);
1639 assert!(cut.contains('…'), "not truncated with an ellipsis: {cut:?}");
1640 assert!(cut.ends_with("\u{1b}[0m"), "lost its reset: {cut:?}");
1641 }
1642
1643 /// The blank line that separates log messages from the bars appears the
1644 /// first time something is logged, and not before: a run that logs nothing
1645 /// should not gain a stray gap above its bars.
1646 #[test]
1647 fn test_log_padding_appears_with_the_first_message() {
1648 let multi = MultiProgress::with_draw_target(ProgressDrawTarget::hidden());
1649 let sink = LogSink {
1650 multi: multi.clone(),
1651 line_ending: "\n",
1652 padding: static_line(&multi),
1653 };
1654 assert_eq!(
1655 sink.padding.message(),
1656 "",
1657 "padded before anything was said"
1658 );
1659
1660 sink.print("something happened");
1661 assert_eq!(sink.padding.message(), " ", "no padding after a message");
1662
1663 // Still exactly one blank line, however much is logged.
1664 sink.print("and again");
1665 assert_eq!(sink.padding.message(), " ");
1666 }
1667
1668 /// The table's furniture is rebuilt for whatever width it is asked for, so
1669 /// a window resized mid-run gets a table laid out for the new size rather
1670 /// than one cut to the old one — and the table drops out entirely, and
1671 /// comes back, as the width crosses the readable threshold.
1672 #[test]
1673 fn test_geometry_follows_the_width_it_is_built_for() {
1674 let names = vec![
1675 "sample_1.fastq.gz".to_string(),
1676 "sample_2.fastq.gz".to_string(),
1677 ];
1678
1679 let narrow = Geometry::new(&names, 50);
1680 assert!(narrow.is_none(), "two columns cannot be readable at 50");
1681
1682 let wide = Geometry::new(&names, 200).expect("two columns fit at 200");
1683 let wider = Geometry::new(&names, 400).expect("two columns fit at 400");
1684 assert_eq!(wide.headings.len(), names.len());
1685
1686 // Every rendered row is exactly as wide as the border above it, and no
1687 // wider than the terminal it was laid out for.
1688 for (geometry, term_width) in [(&wide, 200usize), (&wider, 400)] {
1689 let heading = geometry.row(
1690 &geometry.heading_label,
1691 geometry.headings.iter().map(|h| h[0].as_str()),
1692 );
1693 for line in [&geometry.top, &geometry.divider, &geometry.bottom, &heading] {
1694 assert_eq!(
1695 console::measure_text_width(line),
1696 console::measure_text_width(&geometry.top),
1697 "table lines disagree on width at {term_width} cols"
1698 );
1699 assert!(
1700 console::measure_text_width(line) <= term_width,
1701 "table overflows {term_width} cols"
1702 );
1703 }
1704 }
1705
1706 // A value column never grows past its cap, however wide the window.
1707 assert_eq!(wider.value_width, MAX_VALUE_WIDTH);
1708 }
1709
1710 /// A hidden reporter must be safe to drive exactly like a live one.
1711 #[test]
1712 fn test_hidden_reporter_is_inert() {
1713 let reporter = ProgressReporter::hidden();
1714 let file = reporter.file(0);
1715 file.start("a.fastq");
1716 file.update(1000, || 50.0);
1717 file.stage("writing report");
1718 file.finish("a.fastq", 2000);
1719 file.fail();
1720 assert!(file.live_stats().is_none());
1721 reporter.finish(1, false);
1722 }
1723}