Source code for ttkbootstrap.widgets.meter

"""
    A Meter widget that presents data and progress in a radial style gauge.

    Author: Israel Dryer, israel.dryer@gmail.com
    Modified: 2021-05-4

    Inspired by: https://www.jqueryscript.net/chart-graph/Customizable-Animated-jQuery-HTML5-Gauge-Meter-Plugin.html
"""
import math
from tkinter import StringVar, IntVar
from tkinter import ttk
from tkinter.font import Font
from tkinter.ttk import Frame, Widget

from PIL import Image, ImageTk, ImageDraw
from ttkbootstrap import Style, Colors


[docs]class Meter(Frame): """A radial meter that can be used to show progress of long running operations or the amount of work completed; can also be used as a `Dial` when set to ``interactive=True``. This widget is very flexible. There are two primary meter types which can be set with the ``metertype`` parameter: 'full' and 'semi', which show the arc of the meter in a full or semi-circle. You can also customize the arc of the circle with the ``arcrange`` and ``arcoffset`` parameters. The progress bar indicator can be displayed as a solid color or with stripes using the ``stripethickness`` parameter. By default, the ``stripethickness`` is 0, which results in a solid progress bar. A higher ``stripethickness`` results in larger wedges around the arc of the meter. Various text and label options exist. The center text and progressbar is formatted with the ``meterstyle`` parameter and uses the `TMeter` styles. You can prepend or append text to the center text using the ``textappend`` and ``textprepend`` parameters. This is most commonly used for '$', '%', or other such symbols. Variable are generated automatically for this widget and can be linked to other widgets by referencing them via the ``amountusedvariable`` and ``amounttotalvariable`` attributes. The variable properties allow you to easily get and set the value of these variables. For example: ``Meter.amountused`` or ``Meter.amountused = 55`` will get or set the amount used on the widget without having to call the ``get`` or ``set`` methods of the tkinter variable. """ def __init__(self, master=None, arcrange=None, arcoffset=None, amounttotal=100, amountused=0, interactive=False, labelfont='Helvetica 10 bold', labelstyle='secondary.TLabel', labeltext=None, metersize=200, meterstyle='TMeter', metertype='full', meterthickness=10, showvalue=True, stripethickness=0, textappend=None, textfont='Helvetica 25 bold', textprepend=None, wedgesize=0, **kw): """ Args: master (Widget): Parent widget arcoffset (int): The amount to offset the arc's starting position in degrees; 0 is at 3 o'clock. arcrange (int): The range of the arc in degrees from start to end. amounttotal (int): The maximum value of the meter. amountused (int): The current value of the meter; displayed if ``showvalue=True``. interactive (bool): Enables the meter to be adjusted with mouse interaction. labelfont(Font or str): The font of the supplemental label. labelstyle (str): The ttk style used to render the supplemental label. labeltext (str): Supplemental label text that appears `below` the center text. metersize (int): The size of the meter; represented by one side length of a square. meterstyle (str): The ttk style used to render the meter and center text. metertype (str): One of **full** or **semi**; displays a full-circle or semi-circle. meterthickness (int): The thickness of the meter's progress bar. showvalue (bool): Show the meter's value in the center text; default = True. stripethickness (int): The meter's progress bar can be displayed in solid or striped form. If the value is greater than 0, the meter's progress bar changes from a solid to striped, where the value is the thickness of the stripes. textappend (str): A short string appended to the center text. textfont (Font or str): The font of the center text. textprepend (str): A short string prepended to the center text. wedgesize (int): If greater than zero, the width of the wedge on either side of the current meter value. """ super().__init__(master=master, **kw) self.box = ttk.Frame(self, width=metersize, height=metersize) if metertype == 'semi': self.arcoffset = arcoffset if arcoffset is not None else 135 self.arcrange = arcrange if arcrange is not None else 270 else: # aka 'full' self.arcoffset = arcoffset if arcoffset is not None else -90 self.arcrange = arcrange if arcrange is not None else 360 # widget variables self.amountusedvariable = IntVar(value=amountused) self.amounttotalvariable = IntVar(value=amounttotal) self.labelvariable = StringVar(value=labeltext) self.amountusedvariable.trace_add('write', self.draw_meter) # misc widget settings self.towardsmaximum = True self.metersize = metersize self.meterthickness = meterthickness self.stripethickness = stripethickness self.showvalue = showvalue self.wedgesize = wedgesize # translate system colors if a ttkbootstrap style is not used if 'system' in self.lookup(meterstyle, 'foreground').lower(): self.meterforeground = self.convert_system_color(self.lookup(meterstyle, 'foreground')) else: self.meterforeground = self.lookup(meterstyle, 'foreground') if 'system' in self.lookup(meterstyle, 'background').lower(): self.meterbackground = Colors.update_hsv(self.convert_system_color( self.lookup(meterstyle, 'background')), vd=-0.1) else: self.meterbackground = Colors.update_hsv(self.lookup(meterstyle, 'space'), vd=-0.1) # meter image self.meter = ttk.Label(self.box) self.draw_base_image() self.draw_meter() # text & label widgets self.textcontainer = ttk.Frame(self.box) self.textprepend = ttk.Label(self.textcontainer, text=textprepend, font=labelfont, style=labelstyle) self.textprepend.configure(anchor='s', padding=(0, 5)) self.text = ttk.Label(self.textcontainer, textvariable=self.amountusedvariable, style=meterstyle, font=textfont) self.textappend = ttk.Label(self.textcontainer, text=textappend, font=labelfont, style=labelstyle) self.textappend.configure(anchor='s', padding=(0, 5)) self.label = ttk.Label(self.box, text=labeltext, style=labelstyle, font=labelfont) # set interactive mode if interactive: self.meter.bind('<B1-Motion>', self.on_dial_interact) self.meter.bind('<Button-1>', self.on_dial_interact) # geometry management self.meter.place(x=0, y=0) self.box.pack() if labeltext: self.textcontainer.place(relx=0.5, rely=0.45, anchor='center') else: self.textcontainer.place(relx=0.5, rely=0.5, anchor='center') if textprepend: self.textprepend.pack(side='left', fill='y') if showvalue: self.text.pack(side='left', fill='y') if textappend: self.textappend.pack(side='left', fill='y') self.label.place(relx=0.5, rely=0.6, anchor='center') @property def amountused(self): return self.amountusedvariable.get() @amountused.setter def amountused(self, value): self.amountusedvariable.set(value) @property def amounttotal(self): return self.amounttotalvariable.get() @amounttotal.setter def amounttotal(self, value): self.amounttotalvariable.set(value)
[docs] def convert_system_color(self, systemcolorname): """Convert a system color name to a hexadecimal value Args: systemcolorname (str): a system color name, such as `SystemButtonFace` """ r, g, b = [x >> 8 for x in self.winfo_rgb(systemcolorname)] return f'#{r:02x}{g:02x}{b:02x}'
[docs] def draw_base_image(self): """Draw the base image to be used for subsequent updates""" self.base_image = Image.new('RGBA', (self.metersize * 5, self.metersize * 5)) draw = ImageDraw.Draw(self.base_image) # striped meter if self.stripethickness > 0: for x in range(self.arcoffset, self.arcrange + self.arcoffset, 2 if self.stripethickness == 1 else self.stripethickness): draw.arc((0, 0, self.metersize * 5 - 20, self.metersize * 5 - 20), x, x + self.stripethickness - 1, self.meterbackground, self.meterthickness * 5) # solid meter else: draw.arc((0, 0, self.metersize * 5 - 20, self.metersize * 5 - 20), self.arcoffset, self.arcrange + self.arcoffset, self.meterbackground, self.meterthickness * 5)
[docs] def draw_meter(self, *args): """Draw a meter Args: *args: if triggered by a trace, will be `variable`, `index`, `mode`. """ im = self.base_image.copy() draw = ImageDraw.Draw(im) if self.stripethickness > 0: self.draw_striped_meter(draw) else: self.draw_solid_meter(draw) self.meterimage = ImageTk.PhotoImage(im.resize((self.metersize, self.metersize), Image.CUBIC)) self.meter.configure(image=self.meterimage)
[docs] def draw_solid_meter(self, draw): """Draw a solid meter Args: draw (ImageDraw.Draw): an object used to draw an arc on the meter """ if self.wedgesize > 0: meter_value = self.meter_value() draw.arc((0, 0, self.metersize * 5 - 20, self.metersize * 5 - 20), meter_value - self.wedgesize, meter_value + self.wedgesize, self.meterforeground, self.meterthickness * 5) else: draw.arc((0, 0, self.metersize * 5 - 20, self.metersize * 5 - 20), self.arcoffset, self.meter_value(), self.meterforeground, self.meterthickness * 5)
[docs] def draw_striped_meter(self, draw): """Draw a striped meter Args: draw (ImageDraw.Draw): an object used to draw an arc on the meter """ if self.wedgesize > 0: meter_value = self.meter_value() draw.arc((0, 0, self.metersize * 5 - 20, self.metersize * 5 - 20), meter_value - self.wedgesize, meter_value + self.wedgesize, self.meterforeground, self.meterthickness * 5) else: for x in range(self.arcoffset, self.meter_value() - 1, self.stripethickness): draw.arc((0, 0, self.metersize * 5 - 20, self.metersize * 5 - 20), x, x + self.stripethickness - 1, self.meterforeground, self.meterthickness * 5)
[docs] def lookup(self, style, option): """Wrapper around the tcl style lookup command Args: style (str): the name of the style used for rendering the widget. option (str): the option to lookup from the style option database. Returns: any: the value of the option looked up. """ return self.tk.call("ttk::style", "lookup", style, '-%s' % option, None, None)
[docs] def meter_value(self): """Calculate the meter value Returns: int: the value to be used to draw the arc length of the progress meter """ return int((self.amountused / self.amounttotal) * self.arcrange + self.arcoffset)
[docs] def on_dial_interact(self, e): """Callback for mouse drag motion on indicator Args: e (Event): event callback for drag motion. """ dx = e.x - self.metersize // 2 dy = e.y - self.metersize // 2 rads = math.atan2(dy, dx) degs = math.degrees(rads) if degs > self.arcoffset: factor = degs - self.arcoffset else: factor = 360 + degs - self.arcoffset # clamp value between 0 and ``amounttotal`` amountused = int(self.amounttotal / self.arcrange * factor) if amountused < 0: self.amountused = 0 elif amountused > self.amounttotal: self.amountused = self.amounttotal else: self.amountused = amountused
[docs] def step(self, delta=1): """Increase the indicator value by ``delta``. The default increment is 1. The indicator will reverse direction and count down once it reaches the maximum value. Keyword Args: delta (int): the amount to change the indicator. """ if self.amountused >= self.amounttotal: self.towardsmaximum = True self.amountused = self.amountused - delta elif self.amountused <= 0: self.towardsmaximum = False self.amountused = self.amountused + delta elif self.towardsmaximum: self.amountused = self.amountused - delta else: self.amountused = self.amountused + delta
if __name__ == '__main__': style = Style() root = style.master root.title('ttkbootstrap') Meter(root, metersize=180, padding=20, amountused=25, metertype='semi', labeltext='miles per hour', interactive=True).grid(row=0, column=0) Meter(metersize=180, padding=20, amountused=1800, amounttotal=2600, labeltext='storage used', textappend='gb', meterstyle='info.TMeter', stripethickness=10, interactive=True).grid(row=0, column=1) Meter(metersize=180, padding=20, stripethickness=2, amountused=40, labeltext='project capacity', textappend='%', meterstyle='success.TMeter', interactive=True).grid(row=1, column=0) Meter(metersize=180, padding=20, amounttotal=280, arcrange=180, arcoffset=-180, amountused=75, textappend='°', labeltext='heat temperature', wedgesize=5, meterstyle='danger.TMeter', interactive=True).grid(row=1, column=1) root.mainloop()