Commit | Line | Data |
---|---|---|
c8292b34 MW |
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 | ||
68df6e8f | 26 | package uk.org.distorted.tripe; package object progress { |
c8292b34 MW |
27 | |
28 | /*----- Imports -----------------------------------------------------------*/ | |
29 | ||
68df6e8f MW |
30 | import scala.collection.mutable.{Publisher, Subscriber}; |
31 | ||
32 | import java.lang.Math.ceil; | |
33 | import java.lang.System.currentTimeMillis; | |
c8292b34 MW |
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 | ||
68df6e8f | 46 | private val UDATA = Seq("kB", "MB", "GB", "TB", "PB", "EB"); |
c8292b34 | 47 | def formatBytes(n: Long): String = { |
68df6e8f | 48 | val (x, u) = ((n.toDouble, "B ") /: UDATA) { (xu, n) => (xu, n) match { |
c8292b34 MW |
49 | case ((x, u), name) if x >= 1024.0 => (x/1024.0, name) |
50 | case (xu, _) => xu | |
68df6e8f | 51 | } } |
c8292b34 MW |
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(); } | |
c8292b34 MW |
60 | def begin(job: Job); |
61 | } | |
62 | ||
68df6e8f MW |
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 | |
c8292b34 | 76 | |
68df6e8f | 77 | trait Job extends Publisher[Event] { |
c8292b34 MW |
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 | |
68df6e8f MW |
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(); | |
c8292b34 MW |
91 | |
92 | private[this] val t0 = currentTimeMillis; | |
68df6e8f | 93 | type Pub = Job; |
c8292b34 | 94 | |
68df6e8f MW |
95 | def taken: Double = (currentTimeMillis - t0)/1000.0; |
96 | def eta: Double = | |
c8292b34 MW |
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 | |
68df6e8f | 104 | else taken*(max - cur)/cur.toDouble; |
c8292b34 MW |
105 | } |
106 | ||
68df6e8f MW |
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] { | |
c8292b34 | 114 | private var last = ""; |
68df6e8f MW |
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 | ||
c8292b34 MW |
229 | |
230 | } | |
231 | ||
68df6e8f MW |
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 | ||
c8292b34 | 254 | /*----- That's all, folks -------------------------------------------------*/ |
68df6e8f MW |
255 | |
256 | } |