blob: 4c9373941fe807313138e52e1a30052c17a81f87 [file] [edit]
// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:async';
import 'dart:io';
import 'io.dart';
import 'log.dart' as log;
import 'utils.dart';
/// Tracks elapsed time and progress display state to debounce initial
/// spinners across a group of related operations.
final class ProgressGracePeriod {
/// The default grace period before transient progress is shown for the first
/// time.
final Duration _defaultGracePeriod;
/// Stopwatch tracking time since program start or since last non-progress
/// output.
final Stopwatch _stopwatch;
/// Whether a progress message has been displayed since the last reset.
bool _hasShownProgress;
/// Whether a progress message has been displayed since the last reset.
bool get hasShownProgress => _hasShownProgress;
/// Creates a [ProgressGracePeriod] with an optional [defaultGracePeriod].
///
/// If provided, [stopwatch] is used to track elapsed time. It is an error if
/// [stopwatch] is not running.
///
/// If [hasShownProgress] is `true`, initial progress delay is bypassed
/// immediately.
///
/// It is an error if [defaultGracePeriod] is negative.
ProgressGracePeriod({
Duration defaultGracePeriod = const Duration(milliseconds: 500),
Stopwatch? stopwatch,
bool hasShownProgress = false,
}) : _defaultGracePeriod = defaultGracePeriod,
_stopwatch = stopwatch ?? (Stopwatch()..start()),
_hasShownProgress = hasShownProgress {
if (defaultGracePeriod.isNegative) {
throw ArgumentError.value(
defaultGracePeriod,
'defaultGracePeriod',
'Must not be negative',
);
}
if (stopwatch != null && !stopwatch.isRunning) {
throw ArgumentError.value(stopwatch, 'stopwatch', 'Must be running');
}
}
/// Resets the grace period timer and progress flag.
void reset() {
_stopwatch.reset();
_hasShownProgress = false;
}
/// Calculates the effective delay before transient progress should appear.
Duration get remainingDelay {
if (_hasShownProgress) return Duration.zero;
final remaining = _defaultGracePeriod - _stopwatch.elapsed;
return remaining.isNegative ? Duration.zero : remaining;
}
/// Marks that progress has been shown.
void markProgressShown() {
_hasShownProgress = true;
}
}
final _defaultProgressGracePeriod = ProgressGracePeriod();
final _progressGracePeriodKey = Object();
/// The [ProgressGracePeriod] used by the current [Zone].
ProgressGracePeriod get currentProgressGracePeriod =>
Zone.current[_progressGracePeriodKey] as ProgressGracePeriod? ??
_defaultProgressGracePeriod;
/// Resets the shared grace period timer.
void resetGracePeriod() {
currentProgressGracePeriod.reset();
}
/// Runs [callback] in a [Zone] with [progressGracePeriod] as the active grace
/// period.
R withProgressGracePeriod<R>(
R Function() callback, {
required ProgressGracePeriod progressGracePeriod,
}) => runZoned(
callback,
zoneValues: {_progressGracePeriodKey: progressGracePeriod},
);
/// A live-updating progress indicator for long-running log entries.
final class Progress {
/// The timer used to write "..." during a progress log.
Timer? _timer;
/// The [Stopwatch] used to track how long a progress log has been running.
final _stopwatch = Stopwatch();
/// The progress message as it's being incrementally appended.
///
/// When the progress is done, a single entry will be added to the log for it.
final String _message;
/// Gets the current progress time as a parenthesized, formatted string.
String get _time => '(${niceDuration(_stopwatch.elapsed)})';
/// The length of the most recently-printed [_time] string.
var _timeLength = 0;
/// Whether the initial start message has been printed.
var _hasStarted = false;
/// Whether this progress indicator is transient (erased upon completion).
final bool _transient;
/// Creates a new progress indicator.
///
/// If [fine] is passed, this will log progress messages on [log.Level.fine]
/// as opposed to [log.Level.message].
///
/// If [delay] is passed, the progress animation is only displayed if the
/// operation takes longer than [delay].
///
/// If [transient] is `true`, the progress message will produce no output when
/// not running in a terminal (unless verbose logging is active).
Progress(
this._message, {
bool fine = false,
Duration? delay,
bool transient = false,
}) : _transient = transient {
_stopwatch.start();
final level = fine ? log.Level.fine : log.Level.message;
// The animation is only shown when it would be meaningful to a human.
// That means we're writing a visible message to a TTY at normal log levels
// with ANSI support and non-JSON output.
if (!terminalOutputForStdout ||
!canUseAnsiCodes ||
!log.verbosity.isLevelVisible(level) ||
fine ||
log.verbosity.isLevelVisible(log.Level.fine)) {
if (transient && !log.verbosity.isLevelVisible(log.Level.fine)) {
return;
}
// Not animating, so just log the start and wait until the task is
// completed.
log.write(level, '$_message...');
return;
}
final effectiveDelay = delay ?? Duration.zero;
_timer = Timer.periodic(const Duration(milliseconds: 50), (_) {
if (_stopwatch.elapsed < effectiveDelay) return;
if (!_hasStarted) {
stdout.write('$_message... ');
_hasStarted = true;
currentProgressGracePeriod.markProgressShown();
}
_update();
});
if (effectiveDelay == Duration.zero) {
stdout.write('$_message... ');
_hasStarted = true;
currentProgressGracePeriod.markProgressShown();
}
}
/// Erases the progress message from the terminal.
void _erase() {
stdout.write('\r${log.eraseLine}');
}
/// Stops the progress indicator and prints the final elapsed time.
void stop() {
_stopwatch.stop();
// Always log the final time as [log.fine] because for the most part normal
// users don't care about the precise time information beyond what's shown
// in the animation.
log.fine('$_message finished $_time.');
// If we were animating, print one final update to show the user the final
// time.
if (_timer == null) return;
_timer!.cancel();
_timer = null;
if (_hasStarted) {
_update();
stdout.writeln();
}
}
/// Erases the progress message from the terminal and stops the progress
/// indicator.
void stopAndClear() {
_stopwatch.stop();
if (_timer != null) {
_timer!.cancel();
_timer = null;
if (_hasStarted) {
_erase();
}
}
// Always log the final time as [log.fine] because for the most part normal
// users don't care about the precise time information beyond what's shown
// in the animation.
log.fine('$_message finished $_time.');
}
/// Stop animating the progress indicator.
///
/// This will continue running the stopwatch so that the full time can be
/// logged in [stop].
void stopAnimating() {
if (_timer == null) return;
if (_hasStarted) {
if (_transient) {
_erase();
} else {
// Erase the time indicator so that we don't leave a misleading
// half-complete time indicator on the console.
stdout.write('\r$_message... ${log.eraseToLineEnd}');
stdout.writeln();
}
}
_timeLength = 0;
_timer!.cancel();
_timer = null;
}
/// Refreshes the progress line.
void _update() {
if (log.isMuted) return;
// Show the time only once it gets noticeably long.
if (_stopwatch.elapsed.inSeconds == 0) return;
// Erase the last time that was printed. Erasing just the time using `\b`
// rather than using `\r` to erase the entire line ensures that we don't
// spam progress lines if they're wider than the terminal width.
stdout.write('\b' * _timeLength);
final time = _time;
_timeLength = time.length;
stdout.write(log.gray(time));
}
}