ProgressLine.java
package net.morimekta.terminal.progress;
import net.morimekta.io.tty.TTY;
import net.morimekta.strings.chr.Control;
import net.morimekta.terminal.LinePrinter;
import net.morimekta.terminal.Terminal;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import java.util.concurrent.Flow;
import java.util.function.IntSupplier;
import static java.lang.Math.max;
import static java.util.Objects.requireNonNull;
/**
* Show progress on a single task in how many percent (with spinner and progress-bar). Spinner type is configurable.
* This is the single-thread progress where everything is handled in the same thread as calls {@link #onNext(Progress)}.
* This class is <i>not</i> thread safe.
*/
public class ProgressLine
implements Flow.Subscriber<Progress>, AutoCloseable {
private final Terminal terminal;
private final Spinner spinner;
private final long start;
private final Clock clock;
private final String title;
private final LinePrinter updater;
private final IntSupplier terminalWidthSupplier;
private int spinner_pos;
private double fraction;
private int last_pct;
private int last_pts;
private long last_update;
private long spinner_update;
private long expected_done_ts;
/**
* Create a progress bar using the default terminal.
*
* @param spinner The spinner to use.
* @param title The title of the progress.
* @throws IOException if unable to acquire TTY or set TTY mode.
*/
public ProgressLine(Spinner spinner,
String title) throws IOException {
this(new TTY(), spinner, title);
}
/**
* Create a progress bar using the default terminal.
*
* @param tty TTY instance.
* @param spinner The spinner to use.
* @param title The title of the progress.
* @throws IOException if unable to set TTY mode.
*/
public ProgressLine(TTY tty,
Spinner spinner,
String title) throws IOException {
this(new Terminal(tty),
null,
() -> tty.getTerminalSize().cols,
Clock.systemUTC(),
spinner,
title);
}
/**
* Create a progress bar using the given terminal.
*
* @param terminal The terminal to use.
* @param spinner The spinner to use.
* @param title The title of the progress.
*/
public ProgressLine(Terminal terminal,
Spinner spinner,
String title) {
this(terminal,
null,
() -> terminal.getTTY().getTerminalSize().cols,
Clock.systemUTC(),
spinner,
title);
}
/**
* Create a progress bar using the line printer and width supplier.
*
* @param updater The line printer used to update visible progress.
* @param widthSupplier The width supplier to get terminal width from.
* @param spinner The spinner to use.
* @param title The title of the progress.
*/
public ProgressLine(LinePrinter updater,
IntSupplier widthSupplier,
Spinner spinner,
String title) {
this(null, updater, widthSupplier, Clock.systemUTC(), spinner, title);
}
@Override
public void onSubscribe(Flow.Subscription subscription) {
subscription.request(Long.MAX_VALUE);
}
/**
* Update the progress to reflect the current progress value.
*
* @param current The new current progress value.
*/
@Override
public void onNext(Progress current) {
if (isDone()) {
return;
}
long now = clock.millis();
int pts_w = terminalWidthSupplier.getAsInt() - 2 - title.length();
fraction = current.getRatio();
int pct = (int) (fraction * 100);
int pts = (int) (fraction * pts_w);
if (fraction < 1.0) {
if (now - last_update < 73 && pct == last_pct && pts == last_pts) {
return;
}
long duration_ms = now - start;
Duration remaining = null;
// Progress has actually gone forward, recalculate total time.
if (duration_ms > 3000) {
long remaining_ms;
if (expected_done_ts == 0L || pct > last_pct) {
long assumed_total = (long) (((double) duration_ms) / fraction);
remaining_ms = max(0L, assumed_total - duration_ms);
expected_done_ts = now + remaining_ms;
} else {
remaining_ms = max(0L, expected_done_ts - now);
}
remaining = Duration.of(remaining_ms, ChronoUnit.MILLIS);
}
if (now >= (spinner_update + 100)) {
++spinner_pos;
spinner_update = now;
}
updater.formatln("%s: %s", title, spinner.atProgress(fraction, spinner_pos, remaining, pts_w));
last_pct = pct;
last_pts = pts;
last_update = now;
} else {
var duration = Duration.of(now - start, ChronoUnit.MILLIS);
updater.formatln("%s: %s", title, spinner.atComplete(duration, pts_w));
last_update = Long.MAX_VALUE;
last_pct = 100;
}
}
@Override
public void onComplete() {
onNext(new Progress(1, 1));
}
@Override
public void onError(Throwable throwable) {
close();
}
@Override
public void close() {
long now = clock.millis();
if (now >= last_update) {
int pts_w = terminalWidthSupplier.getAsInt() - 2 - title.length();
updater.formatln("%s: %s",
title,
spinner.atStopped(fraction, "Aborted", pts_w));
last_update = Long.MAX_VALUE;
}
}
public boolean isDone() {
return last_update > clock.millis();
}
/**
* Create a progress updater. Note that <b>either</b> terminal or the updater param must be set.
*
* @param terminal The terminal to print to.
* @param updater The updater to write to.
* @param widthSupplier The width supplier to get terminal width from.
* @param clock The clock to use for timing.
* @param spinner The spinner type.
* @param title What progresses.
*/
protected ProgressLine(Terminal terminal,
LinePrinter updater,
IntSupplier widthSupplier,
Clock clock,
Spinner spinner,
String title) {
this.terminal = terminal;
this.terminalWidthSupplier = requireNonNull(widthSupplier);
this.updater = updater != null ? updater : this::println;
this.spinner = requireNonNull(spinner, "spinner == null");
this.title = requireNonNull(title, "title == null");
this.start = requireNonNull(clock, "clock == null").millis();
this.clock = clock;
this.last_pct = -1;
this.last_pts = -1;
this.last_update = 0;
this.spinner_pos = 0;
this.spinner_update = start;
if (terminal != null) {
terminal.finish();
}
onNext(new Progress(0, 1));
}
private void println(String line) {
terminal.print("\r" + Control.CURSOR_ERASE + line);
}
}