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);
    }
}