chiark / gitweb /
progress.scala: Miscellaneous WIP.
[tripe-android] / progress.scala
1 /* -*-scala-*-
2  *
3  * Reporting progress for long-running jobs
4  *
5  * (c) 2018 Straylight/Edgeware
6  */
7
8 /*----- Licensing notice --------------------------------------------------*
9  *
10  * This file is part of the Trivial IP Encryption (TrIPE) Android app.
11  *
12  * TrIPE is free software: you can redistribute it and/or modify it under
13  * the terms of the GNU General Public License as published by the Free
14  * Software Foundation; either version 3 of the License, or (at your
15  * option) any later version.
16  *
17  * TrIPE is distributed in the hope that it will be useful, but WITHOUT
18  * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
19  * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
20  * for more details.
21  *
22  * You should have received a copy of the GNU General Public License
23  * along with TrIPE.  If not, see <https://www.gnu.org/licenses/>.
24  */
25
26 package uk.org.distorted.tripe; package object progress {
27
28 /*----- Imports -----------------------------------------------------------*/
29
30 import scala.collection.mutable.{Publisher, Subscriber};
31
32 import java.lang.Math.ceil;
33 import java.lang.System.currentTimeMillis;
34
35 /*----- Main code ---------------------------------------------------------*/
36
37 def formatTime(t: Int): String =
38   if (t < -1) "???"
39   else {
40     val (s, t1) = (t%60, t/60);
41     val (m, h) = (t1%60, t1/60);
42     if (h > 0) f"$h%d:$m%02d:$s%02d"
43     else f"$m%02d:$s%02d"
44   }
45
46 private val UDATA = Seq("kB", "MB", "GB", "TB", "PB", "EB");
47 def formatBytes(n: Long): String = {
48   val (x, u) = ((n.toDouble, "B ") /: UDATA) { (xu, n) => (xu, n) match {
49     case ((x, u), name) if x >= 1024.0 => (x/1024.0, name)
50     case (xu, _) => xu
51   } }
52   f"$x%6.1f$u%s"
53 }
54
55 trait Eyecandy {
56   def set(line: String);
57   def clear();
58   def commit();
59   def commit(line: String) { commit(); set(line); commit(); }
60   def begin(job: Job);
61 }
62
63 abstract class Event;                   // other subclasses can be added!
64 abstract class Progress extends Event { def cur: Long; } // it changed
65 object Progress {
66   def unapply(p: Progress) =
67     if (p == null) None
68     else Some(p.cur);
69 }
70 case class Update(override val cur: Long) extends Progress; // progress has been made
71 case class Changed(override val cur: Long) extends Progress; // what or max changed
72 abstract class Stopped extends Event;   // job has stopped
73 case object Done extends Stopped;       // job completed successfuly
74 final case class Failed(why: String) extends Stopped; // job failed
75 case object Cancelled extends Stopped;  // job was cancelled
76
77 trait Job extends Publisher[Event] {
78   def what: String;                     // imperative for what we're doing
79   def cur: Long;                        // current position in work
80   def max: Long;                        // maximum work to do
81   def format: String = {                // describe progress in useful terms
82     val c = cur;
83     val m = max;
84     if (m >= 0) {
85       val fm = m.formatted("%d");
86       s"%${fm.length}d/%s".format(c, fm) // ugh!
87     } else if (c > 0) s"$c"
88     else ""
89   }
90   def cancel();
91
92   private[this] val t0 = currentTimeMillis;
93   type Pub = Job;
94
95   def taken: Double = (currentTimeMillis - t0)/1000.0;
96   def eta: Double =
97     /* Report the estimated time remaining in seconds, or -1 if no idea.
98      *
99      * The model here is very stupid.  Weird jobs should override this and do
100      * something more sensible.
101      */
102
103     if (max < 0 || cur <= 0) -1
104     else taken*(max - cur)/cur.toDouble;
105 }
106
107 /*----- Terminal eyecandy (FIXME: split this out) -------------------------*/
108
109 import java.io.FileDescriptor;
110 import java.lang.System.{out => stdout};
111 import sys.isatty;
112
113 object TerminalEyecandy extends Eyecandy with Subscriber[Event, Job] {
114   private var last = "";
115   var eyecandyp = isatty(FileDescriptor.out);
116
117   /* Assume that characters take up one cell each.  This is going to fail
118    * badly for combining characters, zero-width characters, wide Asian
119    * characters, and lots of other Unicode characters.  The problem is that
120    * Java doesn't have any way to query the display width of a character,
121    * and, honestly, I don't care enough to do the (substantial) work required
122    * to do this properly.
123    */
124
125   def set(line: String) {
126     if (eyecandyp) {
127
128       /* If the old line is longer than the new one, then we must overprint
129        * the end part.
130        */
131       if (line.length < last.length) {
132         val n = last.length - line.length;
133         for (_ <- 0 until n) stdout.write('\b');
134         for (_ <- 0 until n) stdout.write(' ');
135       }
136
137       /* Figure out the length of the common prefix between what we had
138        * before and what we have now.
139        */
140       val m = (0 until (last.length min line.length)) prefixLength
141         { i => last(i) == line(i) };
142
143       /* Delete the tail from the old line and print the new version. */
144       for (_ <- m until last.length) stdout.write('\b');
145       stdout.print(line.substring(m));
146       stdout.flush();
147     }
148
149     /* Update the state. */
150     last = line;
151   }
152
153   def clear() { set(""); }
154
155   def commit() {
156     if (last != "") {
157       if (eyecandyp) stdout.write('\n');
158       else stdout.println(last);
159       last = "";
160     }
161   }
162
163   private final val spinner = """/-\|""";
164   private var step: Int = 0;
165   private final val width = 40;
166
167   def begin(job: Job) { job.subscribe(this); }
168
169   def notify(job: Job, ev: Event) {
170     ev match {
171       case Progress(cur) =>
172         /* Redraw the status line. */
173
174         val max = job.max;
175
176         val sb = new StringBuilder;
177         sb ++= job.what; sb += ' ';
178
179         /* Step the spinner. */
180         step += 1; if (step >= spinner.length) step = 0;
181         sb += spinner(step); sb += ' ';
182
183         /* Progress bar. */
184         if (max < 0)
185           sb ++= "[unknown progress]";
186         else {
187           val n = (width*cur/max).toInt;
188           sb += '[';
189           for (_ <- 0 until n) sb += '=';
190           for (_ <- n until 40) sb += ' ';
191           sb += ']';
192
193           val f = job.format;
194           if (f != "") { sb += ' '; sb ++= f; }
195           sb ++= (100*cur/max).formatted(" %3d%%");
196
197           val eta = job.eta;
198           if (eta >= 0) {
199             sb += ' '; sb += '(';
200             sb ++= formatTime(ceil(eta).toInt);
201             sb += ')';
202           }
203         }
204
205         /* Done. */
206         set(sb.result);
207
208       case Done =>
209         val t = formatTime(ceil(job.taken).toInt);
210         set(s"${job.what} done ($t)"); commit();
211
212       case Cancelled =>
213         set(s"${job.what} CANCELLED"); commit();
214
215       case Failed(msg) =>
216         set(s"${job.what} FAILED: $msg"); commit();
217
218       case _ => ok;
219     }
220   }
221 }
222
223 /*----- Testing cruft -----------------------------------------------------*/
224
225 trait AsyncJob extends Job {
226   protected def run();
227   private var _cur: Long = 0; override def cur = _cur;
228
229   
230 }
231
232
233
234
235 import Thread.sleep;
236
237 class ToyJob(val max: Long) extends Job {
238   val what = "Dummy job";
239   private var _i: Long = 0; def cur = _i;
240
241   def cancel() { ??? }
242   def run() {
243     for (i <- 1l until max) { _i = i; publish(Update(i)); sleep(100); }
244     publish(Done);
245   }
246 }
247
248 def testjob(n: Long) {
249   val j = new ToyJob(n);
250   TerminalEyecandy.begin(j);
251   j.run();
252 }
253
254 /*----- That's all, folks -------------------------------------------------*/
255
256 }