progress.scala: Miscellaneous WIP.
authorMark Wooding <mdw@distorted.org.uk>
Sat, 9 Jun 2018 12:22:16 +0000 (13:22 +0100)
committerMark Wooding <mdw@distorted.org.uk>
Sat, 9 Jun 2018 13:00:09 +0000 (14:00 +0100)
progress.scala

index ddd2b6a..9bfbc64 100644 (file)
  * along with TrIPE.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-package uk.org.distorted.tripe; package object progress;
+package uk.org.distorted.tripe; package object progress {
 
 /*----- Imports -----------------------------------------------------------*/
 
-import Math.ceil;
-import System.currentTimeMillis;
-import System.{err => stderr};         // FIXME: split out terminal progress
+import scala.collection.mutable.{Publisher, Subscriber};
+
+import java.lang.Math.ceil;
+import java.lang.System.currentTimeMillis;
 
 /*----- Main code ---------------------------------------------------------*/
 
@@ -42,12 +43,12 @@ def formatTime(t: Int): String =
     else f"$m%02d:$s%02d"
   }
 
-private val UDATA = Seq("kB", "MB", "GB", "PB", "EB");
+private val UDATA = Seq("kB", "MB", "GB", "TB", "PB", "EB");
 def formatBytes(n: Long): String = {
-  val (x, u) = (n.toDouble, "B ") /: UDATA {
+  val (x, u) = ((n.toDouble, "B ") /: UDATA) { (xu, n) => (xu, n) match {
     case ((x, u), name) if x >= 1024.0 => (x/1024.0, name)
     case (xu, _) => xu
-  }
+  } }
   f"$x%6.1f$u%s"
 }
 
@@ -56,20 +57,43 @@ trait Eyecandy {
   def clear();
   def commit();
   def commit(line: String) { commit(); set(line); commit(); }
-
   def begin(job: Job);
 }
 
+abstract class Event;                  // other subclasses can be added!
+abstract class Progress extends Event { def cur: Long; } // it changed
+object Progress {
+  def unapply(p: Progress) =
+    if (p == null) None
+    else Some(p.cur);
+}
+case class Update(override val cur: Long) extends Progress; // progress has been made
+case class Changed(override val cur: Long) extends Progress; // what or max changed
+abstract class Stopped extends Event;  // job has stopped
+case object Done extends Stopped;      // job completed successfuly
+final case class Failed(why: String) extends Stopped; // job failed
+case object Cancelled extends Stopped; // job was cancelled
 
-trait Job with Publisher[ {
+trait Job extends Publisher[Event] {
   def what: String;                    // imperative for what we're doing
   def cur: Long;                       // current position in work
   def max: Long;                       // maximum work to do
-  def format: String;                  // describe progress in useful terms
+  def format: String = {               // describe progress in useful terms
+    val c = cur;
+    val m = max;
+    if (m >= 0) {
+      val fm = m.formatted("%d");
+      s"%${fm.length}d/%s".format(c, fm) // ugh!
+    } else if (c > 0) s"$c"
+    else ""
+  }
+  def cancel();
 
   private[this] val t0 = currentTimeMillis;
+  type Pub = Job;
 
-  def eta: Int =
+  def taken: Double = (currentTimeMillis - t0)/1000.0;
+  def eta: Double =
     /* Report the estimated time remaining in seconds, or -1 if no idea.
      *
      * The model here is very stupid.  Weird jobs should override this and do
@@ -77,14 +101,156 @@ trait Job with Publisher[ {
      */
 
     if (max < 0 || cur <= 0) -1
-    else ceil((currentTimeMillis - t0)/1000.0 *
-             (max - cur)/cur.toDouble).toInt;
+    else taken*(max - cur)/cur.toDouble;
 }
 
-object TerminalEyecandy extends Eyecandy {
+/*----- Terminal eyecandy (FIXME: split this out) -------------------------*/
+
+import java.io.FileDescriptor;
+import java.lang.System.{out => stdout};
+import sys.isatty;
+
+object TerminalEyecandy extends Eyecandy with Subscriber[Event, Job] {
   private var last = "";
-  var eyecandyp = 
+  var eyecandyp = isatty(FileDescriptor.out);
+
+  /* Assume that characters take up one cell each.  This is going to fail
+   * badly for combining characters, zero-width characters, wide Asian
+   * characters, and lots of other Unicode characters.  The problem is that
+   * Java doesn't have any way to query the display width of a character,
+   * and, honestly, I don't care enough to do the (substantial) work required
+   * to do this properly.
+   */
+
+  def set(line: String) {
+    if (eyecandyp) {
+
+      /* If the old line is longer than the new one, then we must overprint
+       * the end part.
+       */
+      if (line.length < last.length) {
+       val n = last.length - line.length;
+       for (_ <- 0 until n) stdout.write('\b');
+       for (_ <- 0 until n) stdout.write(' ');
+      }
+
+      /* Figure out the length of the common prefix between what we had
+       * before and what we have now.
+       */
+      val m = (0 until (last.length min line.length)) prefixLength
+       { i => last(i) == line(i) };
+
+      /* Delete the tail from the old line and print the new version. */
+      for (_ <- m until last.length) stdout.write('\b');
+      stdout.print(line.substring(m));
+      stdout.flush();
+    }
+
+    /* Update the state. */
+    last = line;
+  }
+
+  def clear() { set(""); }
+
+  def commit() {
+    if (last != "") {
+      if (eyecandyp) stdout.write('\n');
+      else stdout.println(last);
+      last = "";
+    }
+  }
+
+  private final val spinner = """/-\|""";
+  private var step: Int = 0;
+  private final val width = 40;
+
+  def begin(job: Job) { job.subscribe(this); }
+
+  def notify(job: Job, ev: Event) {
+    ev match {
+      case Progress(cur) =>
+       /* Redraw the status line. */
+
+       val max = job.max;
+
+       val sb = new StringBuilder;
+       sb ++= job.what; sb += ' ';
+
+       /* Step the spinner. */
+       step += 1; if (step >= spinner.length) step = 0;
+       sb += spinner(step); sb += ' ';
+
+       /* Progress bar. */
+       if (max < 0)
+         sb ++= "[unknown progress]";
+       else {
+         val n = (width*cur/max).toInt;
+         sb += '[';
+         for (_ <- 0 until n) sb += '=';
+         for (_ <- n until 40) sb += ' ';
+         sb += ']';
+
+         val f = job.format;
+         if (f != "") { sb += ' '; sb ++= f; }
+         sb ++= (100*cur/max).formatted(" %3d%%");
+
+         val eta = job.eta;
+         if (eta >= 0) {
+           sb += ' '; sb += '(';
+           sb ++= formatTime(ceil(eta).toInt);
+           sb += ')';
+         }
+       }
+
+       /* Done. */
+       set(sb.result);
+
+      case Done =>
+       val t = formatTime(ceil(job.taken).toInt);
+       set(s"${job.what} done ($t)"); commit();
+
+      case Cancelled =>
+       set(s"${job.what} CANCELLED"); commit();
+
+      case Failed(msg) =>
+       set(s"${job.what} FAILED: $msg"); commit();
+
+      case _ => ok;
+    }
+  }
+}
+
+/*----- Testing cruft -----------------------------------------------------*/
+
+trait AsyncJob extends Job {
+  protected def run();
+  private var _cur: Long = 0; override def cur = _cur;
+
   
 }
 
+
+
+
+import Thread.sleep;
+
+class ToyJob(val max: Long) extends Job {
+  val what = "Dummy job";
+  private var _i: Long = 0; def cur = _i;
+
+  def cancel() { ??? }
+  def run() {
+    for (i <- 1l until max) { _i = i; publish(Update(i)); sleep(100); }
+    publish(Done);
+  }
+}
+
+def testjob(n: Long) {
+  val j = new ToyJob(n);
+  TerminalEyecandy.begin(j);
+  j.run();
+}
+
 /*----- That's all, folks -------------------------------------------------*/
+
+}