ProgressLine.java
/*
* Copyright 2021 Terminal Utils Authors
*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package net.morimekta.terminal.progress;
import net.morimekta.io.tty.TTY;
import java.io.PrintStream;
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;
import static net.morimekta.strings.StringUtil.isNotEmpty;
import static net.morimekta.strings.chr.Char.CR;
import static net.morimekta.strings.chr.Char.LF;
/**
* 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.
* <p>
* Example usage:
* <pre>{@code
* try (val progress = new ProgressLine(DefaultSpinners.ASCII, "Do Thing")) {
* for (int i = 0; i < 100; ++i) {
* // Do some action that is needed 100 times here.
* progress.onNext(new Progress(i, 100));
* }
* progress.onComplete();
* }
* }</pre>
*/
public class ProgressLine
implements Flow.Subscriber<Progress>, AutoCloseable {
private final PrintStream out;
private final Spinner spinner;
private final long start;
private final Clock clock;
private final String title;
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.
*/
public ProgressLine(Spinner spinner,
String title) {
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.
*/
public ProgressLine(TTY tty,
Spinner spinner,
String title) {
this(System.out,
terminalWith(tty),
Clock.systemUTC(),
spinner,
title);
}
/**
* Create a progress bar using the line printer and width supplier.
*
* @param out 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(PrintStream out,
IntSupplier widthSupplier,
Spinner spinner,
String title) {
this(out, 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;
}
out.print(CR);
out.format("%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);
out.print(CR);
out.format("%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) {
int pts_w = terminalWidthSupplier.getAsInt() - 2 - title.length();
double pct = ((double) last_pct) / 100.0;
var message = throwable == null ? null : throwable.getMessage();
out.print(CR);
out.format("%s: %s", title, spinner.atStopped(
pct, isNotEmpty(message) ? message : "Aborted", pts_w));
last_update = Long.MAX_VALUE;
}
@Override
public void close() {
if (last_update < Long.MAX_VALUE) {
onError(new Exception("Aborted"));
}
out.print(CR);
out.print(LF);
}
/**
* @return If the progress task is done.
*/
public boolean isDone() {
return last_update == Long.MAX_VALUE;
}
/**
* Create a progress updater. Note that <b>either</b> terminal or the updater param must be set.
*
* @param out The terminal to print to.
* @param widthSupplier The width sclock.millis()upplier to get terminal width from.
* @param clock The clock to use for timing.
* @param spinner The spinner type.
* @param title What progresses.
*/
protected ProgressLine(PrintStream out,
IntSupplier widthSupplier,
Clock clock,
Spinner spinner,
String title) {
this.title = requireNonNull(title, "title == null");
this.out = requireNonNull(out, "out == null");
this.spinner = requireNonNull(spinner, "spinner == null");
this.terminalWidthSupplier = requireNonNull(widthSupplier);
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;
onNext(new Progress(0, 1));
}
private static IntSupplier terminalWith(TTY tty) {
return () -> tty.getTerminalSize().cols;
}
}