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 }