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