1#!/usr/bin/env python
2from __future__ import division, print_function
3"""
4A basic widget for showing the progress being made in a task.
5Includes a countdown timer RemainingTime.
6
7History:
82002-03-15 ROwen    Added a start method to TimeBar.
92002-07-30 ROwen    Moved into the RO.Wdg module.
102002-12-20 ROwen    Changed bd to borderwidth for clarity.
112003-07-25 ROwen    ProgressbBar: added barBorder, helpText, helpURL;
12                    can now be resized while in use;
13                    fixed bugs affecting large and small borderwidths.
14                    TimeBar: renamed from TimeRemaining; added countUp and updateInterval;
15                    improved accuracy of pause and resume
162004-05-18 ROwen    Changed ProgressBar.configure to ._configureEvt to avoid collision
17                    with Tkinter.Frame.configure.
182004-08-11 ROwen    Fixed some import errors.
19                    Define __all__ to restrict import.
202004-09-14 ROwen    Modified test code to not import RO.Wdg.
212005-08-03 ROwen    Modified to handle max=min gracefully instead of raising an exception.
22                    Added doc strings to many methods.
232006-03-06 ROwen    Added setUnknown method. To support this, many parameters
24                    now can take two values for (known, unknown) state.
25                    Added barStipple argument.
262012-07-10 ROwen    Modified to use RO.TkUtil.Timer.
27                    Removed use of update_idletasks.
282015-09-24 ROwen    Replace "== None" with "is None" to modernize the code.
29"""
30__all__ = ['ProgressBar', 'TimeBar']
31
32import time
33import RO.SeqUtil
34from RO.TkUtil import Timer
35import Tkinter
36import Button
37import Entry
38import Gridder
39import Label
40
41class ProgressBar (Tkinter.Frame):
42    """A bar graph showing a value or fraction of a task performed.
43
44    Contains three widgets:
45    - labelWdg: an optional label (None if absent)
46    - cnv: the bar graph
47    - numWdg: an optional numerical value display
48
49    Inputs:
50    - minValue: value for zero-length bar
51    - maxValue: value for full-length bar
52    - value: starting value
53    - label: one of: a string, Tkinter Variable, Tkinter widget or None
54    - constrainValue: if value is out of range, pin it else display as supplied
55        the bar graph is always constrained, this only affects the numeric display
56    - valueFormat: numeric value is displayed as valueFormat % value;
57        set to None if not wanted.
58        If two values, the 2nd is the string displayed for unknown value.
59
60    The following control the appearance of the progress bar
61    and the background field against which it is displayed
62    - barLength: length of full bar (pixels) within the background border;
63    - barThick: thickness of bar (pixels) within the background border;
64        if horizontal then defaults to label height, else defaults to 10
65    - barFill: color of bar; if two values, the 2nd is used for unknown value.
66    - barStipple: stipple pattern for bar; if two values, the 2nd is used for unknown value.
67    - barBorder: thickness of black border around progress bar; typically 0 or 1;
68        to the extent possible, this is hidden under the background's border
69    - **kargs options for the bar's background field (a Tkinter Canvas),
70      including the following which default to Entry's default settings
71      - borderwidth: thickness of border around bar's background field
72      - relief: type of border around bar's background field
73      - background: color of bar's background field
74
75    Warnings:
76    - barStipple may only work on unix (due to known bugs in Tk).
77    """
78    UnkValue = "?"
79    def __init__ (self,
80        master,
81        minValue=0,
82        maxValue=100,
83        value=0,
84        label = None,
85        constrainValue = False,
86        valueFormat=("%d", "?"),
87        isHorizontal = True,
88        barLength = 20,
89        barThick = None,
90        barFill = "dark gray",
91        barStipple = (None, "gray50"),
92        barBorder = 0,
93        helpText = None,
94        helpURL = None,
95    **kargs):
96        # handle defaults for background, borderwidth and relief
97        e = Tkinter.Entry()
98        for item in ("background", "borderwidth", "relief"):
99            kargs.setdefault(item, e[item])
100
101        # handle default=0 for other borders
102        for item in ("selectborderwidth", "highlightthickness"):
103            kargs.setdefault(item, 0)
104
105        Tkinter.Frame.__init__(self, master)
106
107        # basics
108        self.constrainValue = constrainValue
109        self.valueFormat = RO.SeqUtil.oneOrNAsList(valueFormat, 2)
110        self.isHorizontal = isHorizontal
111        self.knownInd = 0 # 0 for known, 1 for unknown value
112        self.fullBarLength = barLength
113        if barThick is None:
114            if self.isHorizontal:
115                self.barThick = 0
116            else:
117                self.barThick = 10
118        else:
119            self.barThick = barThick
120        self.barFill = RO.SeqUtil.oneOrNAsList(barFill, 2)
121        self.barStipple = RO.SeqUtil.oneOrNAsList(barStipple, 2)
122        self.barBorder = barBorder
123        self.hideBarCoords = (-1, -1 - self.barBorder) * 2
124        self.helpText = helpText
125        self.helpURL = helpURL
126
127        cnvPadY = 0
128        if self.isHorizontal:
129            labelAnchor = "e"
130            packSide = "left"
131            if barThick is None: # default to label hieght
132                cnvPadY = 5
133                packFill = "both"
134            else:
135                packFill = "x"
136            cnvWidth, cnvHeight = self.fullBarLength, self.barThick
137            numAnchor = "w"
138        else:
139            labelAnchor = "center"
140            packSide = "bottom"
141            packFill = "y"
142            cnvWidth, cnvHeight = self.barThick, self.fullBarLength
143            numAnchor = "center"
144
145        # handle label
146        self.labelWdg = self._makeWdg(label, anchor = labelAnchor)
147        if self.labelWdg is not None:
148            self.labelWdg.pack(side = packSide)
149
150        # create canvas for bar graph
151        self.cnv = Tkinter.Canvas(self,
152            width = cnvWidth,
153            height = cnvHeight,
154        **kargs)
155        self.cnv.pack(side = packSide, expand = True, fill = packFill, pady = cnvPadY)
156
157        # thickness of canvas border; initialize to 0 and compute later
158        # drawable width/height = winfo_width/height - (2 * border)
159        self.cnvBorderWidth = 0
160
161        # bar rectangle (starts out at off screen and zero size)
162        self.barRect = self.cnv.create_rectangle(
163            fill = self.barFill[self.knownInd],
164            stipple = self.barStipple[self.knownInd],
165            width = self.barBorder,
166            *self.hideBarCoords
167        )
168
169        # handle numeric value display
170        if self.valueFormat[0]:
171            self.numWdg = Label.StrLabel(self,
172                anchor = numAnchor,
173                formatStr = self.valueFormat[0],
174                helpText = self.helpText,
175                helpURL = self.helpURL,
176            )
177        elif self.labelWdg is None and self.barThick is None:
178            # use an empty label to force bar thickness
179            def nullFormat(astr):
180                return ""
181            self.numWdg = Label.StrLabel(self, formatFunc = nullFormat)
182        else:
183            self.numWdg = None
184        if self.numWdg is not None:
185            self.numWdg.pack(side = packSide, anchor = numAnchor)
186
187        # set values and update display
188        self.value = value
189        self.minValue = minValue
190        self.maxValue = maxValue
191
192        self.cnv.bind('<Configure>', self._configureEvt)
193        self._setSize()
194
195    def clear(self):
196        """Hide progress bar and associated value label"""
197        self.cnv.coords(self.barRect, *self.hideBarCoords)
198        if self.numWdg:
199            self.numWdg["text"] = ""
200
201    def setValue(self,
202        newValue,
203        newMin = None,
204        newMax = None,
205    ):
206        """Set the current value and optionally one or both limits.
207
208        Note: always computes bar scale, numWdg width and calls update.
209        """
210        self.knownInd = 0
211        if self.constrainValue:
212            newValue = max(min(newValue, self.maxValue), self.minValue)
213        self.value = newValue
214        self.setLimits(newMin, newMax)
215
216    def setUnknown(self):
217        """Display an unknown value"""
218        self.knownInd = 1
219        self.value = self.maxValue
220        self.fullUpdate()
221
222    def setLimits(self,
223        newMin = None,
224        newMax = None,
225    ):
226        """Set one or both limits.
227
228        Note: always computes bar scale, numWdg width and calls update.
229        """
230        if newMin is not None:
231            self.minValue = newMin
232        if newMax is not None:
233            self.maxValue = newMax
234        self.fullUpdate()
235
236    def fullUpdate(self):
237        """Redisplay assuming settings have changed
238        (e.g. current value, limits or isKnown).
239        Compute current bar scale and numWdg width and then display.
240        """
241        # compute bar scale
242        try:
243            self.barScale = float(self.fullBarLength) / float(self.maxValue - self.minValue)
244        except ArithmeticError:
245            self.barScale = 0.0
246
247        # set bar color scheme
248        self.cnv.itemconfig(
249            self.barRect,
250            fill = self.barFill[self.knownInd],
251            stipple = self.barStipple[self.knownInd],
252        )
253
254        # set width of number widget
255        if self.numWdg:
256            # print "valfmt=%r, knownInd=%r, minValue=%r, maxValue=%r" % (self.valueFormat, self.knownInd, self.minValue, self.maxValue)
257            if self.knownInd == 0:
258                maxLen = max([len(self.valueFormat[0] % (val,)) for val in (self.minValue, self.maxValue)])
259            else:
260                maxLen = len(self.valueFormat[1])
261            self.numWdg["width"] = maxLen
262
263        self.update()
264
265    def update(self):
266        """Redisplay based on current values."""
267        # 0,0 is upper-left corner of canvas, but *includes* the border
268        # thus positions must be offset
269        # the smallest starting position required is 1 - self.cnvBorderWidth
270        # but -1 is simpler and works for all cases
271        value=self.value
272
273        barLength = self._valueToLength(value)
274        if barLength <= 0:
275            # works around some a Tk bug or misfeature
276            x0, y0, x1, y1 = self.hideBarCoords
277        elif barLength >= self.fullBarLength:
278            # works around a Tk bug or misfeature that is only relevant if the bar has a border
279            x0 = y0 = 0
280            x1 = self.cnvSize[0] - 1
281            y1 = self.cnvSize[1] - 1
282        elif self.isHorizontal:
283            x0 = 0
284            y0 = 0
285            x1 = self._valueToLength(value) + self.cnvBorderWidth
286            y1 = self.cnvSize[1] - 1
287        else:
288            x0 = 0
289            y0 = (self.fullBarLength - self._valueToLength(value)) + self.cnvBorderWidth
290            x1 = self.cnvSize[0] - 1
291            y1 = self.cnvSize[1] - 1
292        # print "x0, y0, x1, y1 =", x0, y0, x1, y1
293        self.cnv.coords(self.barRect, x0, y0, x1, y1)
294
295        # And update the label
296        if self.numWdg:
297            if self.knownInd == 0:
298                self.numWdg["text"] = self.valueFormat[0] % (value,)
299            else:
300                self.numWdg["text"] = self.valueFormat[1]
301
302    def _configureEvt(self, evt=None):
303        """Handle the <Configure> event.
304        """
305        self._setSize()
306        self.update()
307
308    def _makeWdg(self, wdgInfo, **kargs):
309        """Return a widget depending on wdgInfo:
310        - None or False: returns None or False
311        - a string: returns a Label with text=wdgInfo
312        - a Tkinter Variable: returns a Label with textvariable=wdgInfo
313        - a Tkinter Widget: returns wdgInfo unaltered
314
315        kargs is ignored if wdgInfo is a widget
316        """
317        if wdgInfo is None:
318            return wdgInfo
319        elif isinstance(wdgInfo, Tkinter.Widget):
320            # a widget; assume it's a Label widget of some kind
321            return wdgInfo
322
323        # at this point we know we are going to create our own widget
324        # set up the keyword arguments
325        kargs.setdefault("helpText", self.helpText)
326        kargs.setdefault("helpURL", self.helpURL)
327        if isinstance(wdgInfo, Tkinter.Variable):
328            kargs["textvariable"] = wdgInfo
329        else:
330            kargs["text"] = wdgInfo
331
332        return Label.StrLabel(self, **kargs)
333
334    def _setSize(self):
335        """Compute or recompute bar size and associated values."""
336        # update border width
337        self.cnvBorderWidth = int(self.cnv["borderwidth"]) + int(self.cnv["selectborderwidth"]) + int(self.cnv["highlightthickness"])
338        self.cnvSize = [size for size in (self.cnv.winfo_width(), self.cnv.winfo_height())]
339        barSize = [size - (2 * self.cnvBorderWidth) for size in self.cnvSize]
340
341        # compute bar length
342        if self.isHorizontal:
343            self.fullBarLength = barSize[0]
344        else:
345            self.fullBarLength = barSize[1]
346        # print "_setSize; self.fullBarLength =", self.fullBarLength
347
348        # recompute scale and update bar display
349        self.fullUpdate()
350
351    def _valueToLength(self, value):
352        """Compute the length of the bar, in pixels, for a given value.
353        This is the desired length, in pixels, of the colored portion of the bar.
354        """
355        barLength = (float(value) - self.minValue) * self.barScale
356        # print "barLength =", barLength
357        barLength = int(barLength + 0.5)
358        # print "barLength =", barLength
359        return barLength
360
361
362class TimeBar(ProgressBar):
363    """Progress bar to display elapsed or remaining time in seconds.
364    Inputs:
365    - countUp: if True, counts up, else counts down
366    - autoStop: automatically stop when the limit is reached
367    - updateInterval: how often to update the display (sec)
368    **kargs: other arguments for ProgressBar, including:
369      - value: initial time;
370        typically 0 for a count up timer, maxValue for a countdown timer
371        if omitted then 0 is shown and the bar does not progress until you call start.
372      - minvalue: minimum time; typically 0
373      - maxValue: maximum time
374    """
375    def __init__ (self,
376        master,
377        countUp = False,
378        valueFormat = ("%3.0f sec", "??? sec"),
379        autoStop = False,
380        updateInterval = 0.1,
381    **kargs):
382        ProgressBar.__init__(self,
383            master = master,
384            valueFormat = valueFormat,
385            **kargs
386        )
387        self._autoStop = bool(autoStop)
388        self._countUp = bool(countUp)
389
390        self._updateInterval = updateInterval
391        self._updateTimer = Timer()
392        self._startTime = None
393
394        if "value" in kargs:
395            self.start(kargs["value"])
396
397    def clear(self):
398        """Set the bar length to zero, clear the numeric time display and stop the timer.
399        """
400        self._updateTimer.cancel()
401        ProgressBar.clear(self)
402        self._startTime = None
403
404    def pause(self, value = None):
405        """Pause the timer.
406
407        Inputs:
408        - value: the value at which to pause; if omitted then the current value is used
409
410        Error conditions: does nothing if not running.
411        """
412        if self._updateTimer.cancel():
413            # update timer was running
414            if value:
415                self.setValue(value)
416            else:
417                self._updateTime(reschedule = False)
418
419    def resume(self):
420        """Resume the timer from the current value.
421
422        Does nothing if not paused or running.
423        """
424        if self._startTime is None:
425            return
426        self._startUpdate()
427
428    def start(self, value = None, newMin = None, newMax = None, countUp = None):
429        """Start the timer.
430
431        Inputs:
432        - value: starting value; if None, set to 0 if counting up, max if counting down
433        - newMin: minimum value; if None then the existing value is used
434        - newMax: maximum value: if None then the existing value is used
435        - countUp: if True/False then count up/down; if None then the existing value is used
436
437        Typically you will only specify newMax or nothing.
438        """
439        if newMin is not None:
440            self.minValue = float(newMin)
441        if newMax is not None:
442            self.maxValue = float(newMax)
443        if countUp is not None:
444            self._countUp = bool(countUp)
445
446        if value is not None:
447            value = float(value)
448        elif self._countUp:
449            value = 0.0
450        else:
451            value = self.maxValue
452        self.setValue(value)
453
454        self._startUpdate()
455
456    def _startUpdate(self):
457        """Starts updating from the current value.
458        """
459        if self._countUp:
460            self._startTime = time.time() - self.value
461        else:
462            self._startTime = time.time() - (self.maxValue - self.value)
463        self._updateTime()
464
465    def _updateTime(self, reschedule = True):
466        """Automatically update the elapsed time display.
467        Call once to get things going.
468        """
469        # print "_updateTime called"
470        # cancel pending update, if any
471        self._updateTimer.cancel()
472
473        if self._startTime is None:
474            raise RuntimeError("bug! nothing to update")
475
476        # update displayed value
477        if self._countUp:
478            value = time.time() - self._startTime
479            if (self._autoStop and value >= self.maxValue):
480                self.setValue(self.maxValue)
481                return
482        else:
483            value = (self._startTime + self.maxValue) - time.time()
484            if (self._autoStop and value <= 0.0):
485                self.setValue(0)
486                return
487
488        self.setValue(value)
489
490        # if requested, schedule next update
491        if reschedule:
492            self._updateTimer.start(self._updateInterval, self._updateTime)
493
494
495if __name__ == "__main__":
496    import PythonTk
497    root = PythonTk.PythonTk()
498
499    # horizontal and vertical progress bars
500
501    hProg = [ProgressBar(
502                root,
503                isHorizontal = True,
504                borderwidth = bw,
505                barLength = 99,
506                relief = "solid",
507            ) for bw in (0, 1, 2, 10)
508        ]
509    vProg = [ProgressBar(
510                root,
511                isHorizontal = False,
512                borderwidth = bw,
513                barLength = 99,
514                relief = "solid",
515            ) for bw in (0, 1, 2, 10)
516        ]
517
518    def setProg(*args):
519        for pb in hProg + vProg:
520            pb.setValue (
521                newValue = valEntry.getNum(),
522                newMin = minEntry.getNum(),
523                newMax = maxEntry.getNum(),
524            )
525
526    valEntry = Entry.IntEntry(root, defValue = 50, width=5, callFunc = setProg)
527    minEntry = Entry.IntEntry(root, defValue =  0, width=5, callFunc = setProg)
528    maxEntry = Entry.IntEntry(root, defValue = 99, width=5, callFunc = setProg)
529
530    setProg()
531
532    gr = Gridder.Gridder(root)
533
534    gr.gridWdg ("Val", valEntry)
535    gr.gridWdg ("Minimum", minEntry)
536    gr.gridWdg ("Maximum", maxEntry)
537    gr.gridWdg (False)  # spacer row
538
539
540    vbarRowSpan = gr.getNextRow()
541    col = gr.getNextCol()
542    for vpb in vProg:
543        vpb.grid(row=0, column = col, rowspan = vbarRowSpan)
544        col += 1
545    root.grid_rowconfigure(vbarRowSpan-1, weight=1)
546
547    hbarColSpan = col + 1
548    for hpb in hProg:
549        gr.gridWdg(False, hpb, colSpan=hbarColSpan, sticky="")
550
551    root.grid_columnconfigure(hbarColSpan-1, weight=1)
552
553    # time bars
554
555    def startRemTime():
556        time = timeEntry.getNum()
557        for bar in timeBars:
558            bar.start(time)
559
560    def pauseRemTime():
561        for bar in timeBars:
562            bar.pause()
563
564    def resumeRemTime():
565        for bar in timeBars:
566            bar.resume()
567
568    def clearRemTime():
569        for bar in timeBars:
570            bar.clear()
571
572
573    timeEntry = Entry.IntEntry(root, defValue = 9, width=5)
574    gr.gridWdg ("Rem Time", timeEntry)
575    gr.gridWdg (False, (
576            Button.Button(root, text="Start", command=startRemTime),
577            Button.Button(root, text="Pause", command=pauseRemTime),
578            Button.Button(root, text="Resume", command=resumeRemTime),
579            Button.Button(root, text="Clear", command=clearRemTime),
580        ),
581    )
582
583    hTimeBar = [TimeBar(
584            master=root,
585            label = "Time:",
586            isHorizontal = True,
587            autoStop = countUp,
588            valueFormat = "%3.1f sec",
589            countUp = countUp,
590        ) for countUp in (False, True)
591    ]
592    for hbar in hTimeBar:
593        gr.gridWdg(False, hbar, colSpan=hbarColSpan, sticky="ew")
594
595    vTimeBar = [TimeBar(
596            master=root,
597            label = "Time",
598            isHorizontal = False,
599            autoStop = not countUp,
600            countUp = countUp,
601        ) for countUp in (False, True)
602    ]
603    col = gr.getNextCol()
604    for vbar in vTimeBar:
605        vbar.grid(row=0, column=col, rowspan = gr.getNextRow(), sticky="ns")
606        col += 1
607    timeBars = hTimeBar + vTimeBar
608
609    root.mainloop()
610