All Downloads are FREE. Search and download functionalities are using the official Maven repository.

net.morimekta.console.terminal.ProgressManager Maven / Gradle / Ivy

Go to download

Utilities helping with various *nix console topics. Mostly geared toward expressive and interactive command line applications.

The newest version!
package net.morimekta.console.terminal;

import net.morimekta.console.chr.Char;
import net.morimekta.console.chr.Color;
import net.morimekta.util.Strings;

import javax.annotation.Nonnull;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Queue;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;

import static java.lang.Math.max;
import static java.lang.Math.min;
import static java.util.Comparator.comparing;
import static net.morimekta.console.chr.Control.cursorUp;
import static net.morimekta.console.terminal.Progress.format;

/**
 * Show progress on a number of tasks. The tasks can be dynamically created
 * and finished. E.g if a number of large files needs to be downloaded they
 * can be given a task each, and only a certain number of files will be downloaded
 * at the same time. Example:
 *
 * 
{@code
 * try (ProgressManager progress = new ProgressManager(term, Progress.Spinner.CLOCK)) {
 *     Future first = progress.addTask("First Task", 10000, task -> {
 *         // All the work
 *         task.accept(10000);
 *         return "OK";
 *     });
 *     Future second = progress.addTask("Second Task", 10000, task -> {
 *         // All the work
 *         task.accept(10000);
 *         return "OK";
 *     });
 *
 *     progress.waitAbortable();
 *
 *     term.println("First: " + first.get());
 *     term.println("Second: " + second.get());
 * } finally {
 *     term.println();
 * }
 * }
*/ public class ProgressManager implements AutoCloseable { @FunctionalInterface public interface ProgressHandler { T handle(@Nonnull ProgressTask progress) throws Exception; } @FunctionalInterface public interface ProgressAsyncHandler { void handle(@Nonnull CompletableFuture result, @Nonnull ProgressTask progress); } /** * Create a progress bar using the given terminal. * * @param terminal The terminal to use. * @param spinner The spinner to use. */ public ProgressManager(@Nonnull Terminal terminal, @Nonnull Progress.Spinner spinner) { this(terminal, spinner, DEFAULT_MAX_TASKS); } /** * Create a progress bar using the given terminal. * * @param terminal The terminal to use. * @param spinner The spinner to use. * @param max_tasks Maximum number fo concurrent inProgress. */ public ProgressManager(@Nonnull Terminal terminal, @Nonnull Progress.Spinner spinner, int max_tasks) { this(terminal, spinner, max_tasks, Executors.newScheduledThreadPool(max_tasks + 1, new ThreadFactory() { private AtomicInteger next = new AtomicInteger(1); @Nonnull @Override public Thread newThread(@Nonnull Runnable runnable) { Thread thread = new Thread(runnable); thread.setName("progress-" + next.getAndIncrement()); return thread; } }), Clock.systemUTC()); } /** * Create a progress updater. Note that either terminal or the * updater param must be set. * * @param terminal The terminal to print to. * @param spinner The spinner type. * @param executor The executor to run updater task in. * @param clock The clock to use for timing. */ ProgressManager(Terminal terminal, Progress.Spinner spinner, int maxTasks, ScheduledExecutorService executor, Clock clock) { this.terminal = terminal; this.executor = executor; this.clock = clock; this.spinner = spinner; this.maxTasks = maxTasks; this.startedTasks = new ArrayList<>(); this.queuedTasks = new ConcurrentLinkedQueue<>(); this.buffer = new LineBuffer(terminal); this.isWaiting = new AtomicBoolean(false); this.updater = executor.scheduleAtFixedRate(this::doUpdate, 10L, 100L, TimeUnit.MILLISECONDS); } /** * Close the progress and all tasks associated with it. */ @Override public void close() { synchronized (startedTasks) { if (executor.isShutdown()) { return; } // ... stop updater thread. Do not interrupt. executor.shutdown(); try { updater.get(); } catch (InterruptedException | CancellationException | ExecutionException ignore) { // Ignore these closing thread errors. } // And stop all the tasks, do interrupting. for (InternalTask task : startedTasks) { task.cancel(true); } for (InternalTask task : queuedTasks) { task.close(); } try { executor.awaitTermination(100L, TimeUnit.MILLISECONDS); } catch (InterruptedException ignore) { Thread.currentThread().interrupt(); } } } /** * Wait for all scheduled tasks to finish allowing the user to abort all * tasks with <ctrl>-C. * * @throws IOException If interrupted by user. * @throws InterruptedException If interrupted by system or other threads. */ public void waitAbortable() throws IOException, InterruptedException { try { isWaiting.set(true); terminal.waitAbortable(updater); } finally { close(); updateLines(); terminal.finish(); } } /** * Add a task to be done while showing progress. If there are too many tasks * ongoing, the task will be queued and done when the local thread pool has * available threads. * * @param title The progress title of the task. * @param total The total progress to complete. * @param handler The handler to do the task behind the progress being shown. * @param The return type for the task. * @return The future returning the task result. */ public Future addTask(String title, long total, ProgressAsyncHandler handler) { if (executor.isShutdown()) { throw new IllegalStateException("Adding task to closed progress manager"); } InternalTask task = new InternalTask<>(title, total, handler); queuedTasks.add(task); startTasks(); return task; } /** * Add a task to be done while showing progress. If there are too many tasks * ongoing, the task will be queued and done when the local thread pool has * available threads. * * @param title The progress title of the task. * @param total The total progress to complete. * @param handler The handler to do the task behind the progress being shown. * @param The return type for the task. * @return The future returning the task result. */ public Future addTask(String title, long total, ProgressHandler handler) { ProgressAsyncHandler async = (result, progress) -> { try { result.complete(handler.handle(progress)); } catch (Exception e) { if (!result.isCancelled()) { result.completeExceptionally(e); } } }; return addTask(title, total, async); } protected List lines() { return buffer.lines(); } // ------ private ------ private static final int DEFAULT_MAX_TASKS = 5; private final Terminal terminal; private final ExecutorService executor; private final Progress.Spinner spinner; private final Clock clock; private final Future updater; private final ArrayList startedTasks; private final Queue queuedTasks; private final LineBuffer buffer; private final AtomicBoolean isWaiting; private final int maxTasks; private void startTasks() { synchronized (startedTasks) { if (executor.isShutdown()) { return; } int toAdd = maxTasks; for (InternalTask task : startedTasks) { if (!task.isDone()) { --toAdd; } } while (toAdd-- > 0 && !queuedTasks.isEmpty()) { InternalTask task = queuedTasks.poll(); startedTasks.add(task); task.start(); } } } private int getTerminalWidth() { return terminal.getTTY().getTerminalSize().cols; } private boolean isDone() { synchronized (startedTasks) { if (!queuedTasks.isEmpty()) return false; for (InternalTask task : startedTasks) { if (!task.isDone()) { return false; } } } return true; } private void doUpdate() { updateLines(); if (isWaiting.get() && isDone()) { updater.cancel(false); } } private void updateLines() { List updatedLines = new ArrayList<>(); synchronized (startedTasks) { // Make sure we read terminal size before each actual update. terminal.getTTY().clearCachedTerminalSize(); int maxUpdating = Math.min(terminal.getTTY().getTerminalSize().rows, maxTasks * 2); if (startedTasks.size() > maxUpdating) { // If we have more started then number of available rows: // Clear the buffer (should clear all lines and move buffer.clear(); terminal.print("" + Char.CR); // First try to move all completed tasks off the started task list // without changing the already completed order. Move the already // completed tasks off that are all before non-completed tasks. Iterator it = startedTasks.iterator(); while (it.hasNext()) { InternalTask next = it.next(); if (next.fraction >= 1.0) { terminal.print(renderTask(next)); terminal.println(); it.remove(); } else { break; } } if (startedTasks.size() > maxUpdating) { // If the list of started tasks is still too large move all completed // tasks first, and then repeat. // SORT completed BEFORE not-completed. startedTasks.sort(comparing(t -> t.fraction < 1.0)); it = startedTasks.iterator(); while (it.hasNext()) { InternalTask next = it.next(); if (next.fraction >= 1.0) { terminal.print(renderTask(next)); terminal.println(); it.remove(); } else { break; } } } // And 1 back. terminal.print(cursorUp(1)); } for (InternalTask task : startedTasks) { updatedLines.add(renderTask(task)); } if (queuedTasks.size() > 0) { updatedLines.add(" -- And " + queuedTasks.size() + " more..."); } } if (updatedLines.size() > 0) { while (buffer.count() < updatedLines.size()) { buffer.add(""); } buffer.update(0, updatedLines); } } private String renderTask(@Nonnull InternalTask task) { long now = clock.millis(); synchronized (task) { int pts_w = getTerminalWidth() - 23 - task.title.length(); int pct = (int) (task.fraction * 100); int pts = (int) (task.fraction * pts_w); int remaining_pts = pts_w - pts; if (task.isCancelled()) { return String.format("%s: [%s%s%s%s%s] %3d%% %sCancelled%s", task.title, Color.GREEN, Strings.times(spinner.done.toString(), pts), Color.YELLOW, Strings.times(spinner.remain.toString(), remaining_pts), Color.CLEAR, pct, new Color(Color.RED, Color.BOLD), Color.CLEAR); } else if (task.isCompletedExceptionally()) { return String.format("%s: [%s%s%s%s%s] %3d%% %sFailed%s", task.title, Color.GREEN, Strings.times(spinner.done.toString(), pts), Color.YELLOW, Strings.times(spinner.remain.toString(), remaining_pts), Color.CLEAR, pct, new Color(Color.RED, Color.BOLD), Color.CLEAR); } else if (task.fraction < 1.0) { Duration remaining = null; // Progress has actually gone forward, recalculate total time. if (task.expected_done_ts > 0) { long remaining_ms = max(0L, task.expected_done_ts - now); remaining = Duration.of(remaining_ms, ChronoUnit.MILLIS); } int spinner_pos = task.spinner_pos % spinner.spinner.length; return String.format("%s: [%s%s%s%s%s] %3d%% %s%s%s%s", task.title, Color.GREEN, Strings.times(spinner.done.toString(), pts), Color.YELLOW, Strings.times(spinner.remain.toString(), remaining_pts), Color.CLEAR, pct, new Color(Color.YELLOW, Color.BOLD), spinner.spinner[spinner_pos], Color.CLEAR, remaining == null ? "" : " + " + format(remaining)); } else { return String.format("%s: [%s%s%s] 100%% %s%s%s @ %s", task.title, Color.GREEN, Strings.times(spinner.done.toString(), pts_w), Color.CLEAR, new Color(Color.GREEN, Color.BOLD), spinner.complete, Color.CLEAR, format(Duration.of(task.updated_ts - task.started_ts, ChronoUnit.MILLIS))); } } } class InternalTask extends CompletableFuture implements Runnable, ProgressTask { final long total; final long created_ts; final String title; final AtomicReference> future; final ProgressAsyncHandler handler; volatile int spinner_pos; volatile long spinner_update_ts; volatile long started_ts; volatile long updated_ts; volatile long updated_time_ts; volatile long expected_done_ts; volatile double fraction; /** * Create a progress updater. Note that either terminal or the * updater param must be set. * * @param title What progresses. * @param total The total value to be 'progressed'. */ InternalTask(String title, long total, ProgressAsyncHandler handler) { this.title = title; this.total = total; this.handler = handler; this.future = new AtomicReference<>(); this.spinner_pos = 0; this.spinner_update_ts = 0L; this.created_ts = clock.millis(); this.started_ts = 0; this.fraction = 0.0; } void start() { synchronized (this) { if (isCancelled()) { throw new IllegalStateException("Starting cancelled task"); } if (started_ts > 0) { throw new IllegalStateException("Already Started"); } started_ts = clock.millis(); spinner_update_ts = started_ts; future.set(executor.submit(() -> { try { handler.handle(this, this); } catch (Exception e) { if (!isCancelled()) { completeExceptionally(e); } } })); } } @Override public void run() { synchronized (this) { if (isDone()) { return; } if (started_ts > 0) { throw new IllegalStateException("Already Started"); } started_ts = clock.millis(); spinner_update_ts = started_ts; } try { handler.handle(this, this); } catch (Exception e) { completeExceptionally(e); } } @Override public boolean complete(T t) { try { synchronized (this) { if (isDone()) { return false; } accept(total); return super.complete(t); } } finally { startTasks(); } } @Override public boolean completeExceptionally(Throwable throwable) { try { synchronized (this) { if (isDone()) { return false; } stopInternal(); return super.completeExceptionally(throwable); } } finally { startTasks(); } } @Override public boolean cancel(boolean interruptable) { try { synchronized (this) { if (isDone()) { return false; } stopInternal(); super.cancel(interruptable); } Future f = future.get(); return f != null && !f.isDone() && f.cancel(interruptable); } finally { startTasks(); } } @Override public void close() { cancel(true); } /** * Update the progress to reflect the current progress value. * * @param current The new current progress value. */ @Override public void accept(long current) { synchronized (this) { if (isCancelled()) { throw new IllegalStateException("Task is cancelled"); } if (isDone()) return; long now = clock.millis(); current = min(current, total); if (now >= (spinner_update_ts + 100)) { spinner_pos = spinner_pos + 1; spinner_update_ts = now; } if (current < total) { fraction = ((double) current) / ((double) total); long duration_ms = now - started_ts; if (duration_ms > 3000) { // Progress has actually gone forward, recalculate total time only if // we have 3 second of progress. if (expected_done_ts == 0L || updated_time_ts < (now - 2000L)) { // Update total / expected time once per 2 seconds. long assumed_total = (long) (((double) duration_ms) / fraction); long remaining_ms = max(0L, assumed_total - duration_ms); expected_done_ts = now + remaining_ms; updated_time_ts = now; } } } else { fraction = 1.0; expected_done_ts = now; } updated_ts = now; } } private void stopInternal() { long now = clock.millis(); this.updated_ts = now; this.updated_time_ts = now; this.spinner_update_ts = now; this.expected_done_ts = 0L; } } }




© 2015 - 2024 Weber Informatics LLC | Privacy Policy