25 November 2016

Out of the long list of cross-platform python GUI options, the two that I’m most familiar with are Tkinter and PySide. The purpose of this post is to demonstrate how to create the same application with a separate data acquisition thread using both of these libraries and discuss the differences between them. The code for these sample applications is available in my github repository python-gui-demos.

The Program

The demo program measures the amount of waking day elapsed, calculated by comparing the current time against the a set wake time and bed time in the config file. The waking day elapsed is displayed using a progress bar and a label, a pause button prevents these elements from updating, and a quit button closes the application.

This is accomplished by defining a GuiPart object and a ThreadedClient object which can communicate between each other. For the sake of comparison, I’ve used the same names for both implementations:

GuiPart ThreadedClient thread day_bar day_label pause_button quit_button pause() quit_click() update(current_datetime) parent run()

The GUI uses a grid layout and looks like this:

day_bar day_label program_label pause_button quit_button

The grid layout has 3 rows and 2 columns, and the progress bar spans two rows.

Implementation

Setting Up Objects

Through Qt, PySide has a thread object - the QtCore QThread. Tkinter does not provide a tk-flavoured thread object, so we’ll use the standard library thread object. In python 2, the tkinter Tk object is implemented as a classobj instead of a type, so in order for our GUI to inherit it be have to make it a metaclass of Tk and object.

PySideTkinter
class GuiPart(QtGui.QWidget):
    ...

class ThreadedClient(QtCore.QThread):
    ...
class GuiPart(tk.Tk, object):
    ...

class ThreadedClient(threading.Thread):
    ...

Widget Layout

In PySide, the layout is a separate object from the main window’s QWidget called QGridLayout. GUI widgets are then added to this main layout with the row/column/rowspan and columnspan information. In Tkinter, the main window is passed as the first variable to each GUI widget, and then can be packed into a grid using the .grid() method. You can also add elements without a grid layout using .pack(), but combining calls to .pack() and .grid() will result in errors.

PySideTkinter
self.main_layout = QtGui.QGridLayout()
self.day_bar = QtGui.QProgressBar(self)
self.day_label = QtGui.QLabel(self)
self.pause_button = QtGui.QPushButton("Pause", self)
self.quit_button = QtGui.QPushButton("Quit", self)
program_label = QtGui.QLabel(cfg.get("variables", "main_label"), self)
self.main_layout.addWidget(program_label, 0, 0, 1, 1)
self.main_layout.addWidget(self.day_label, 1, 0, 1, 1)
self.main_layout.addWidget(self.day_bar, 0, 1, 2, 1)
self.main_layout.addWidget(self.pause_button, 2, 0, 1, 1)
self.main_layout.addWidget(self.quit_button, 2, 1, 1, 1)
self.setLayout(self.main_layout)
self.day_bar = ttk.Progressbar(self)
self.day_bar.grid(row=0, column=1, rowspan=2)
self.day_label = tk.Label(self)
self.day_label.grid(row=1, column=0)
program_label = ttk.Label(self, text=cfg.get("variables", "main_label"))
program_label.grid(row=0, column=0)
self.pause_button = ttk.Button(self)
self.pause_button.grid(row=2, column=0)
self.quit_button = ttk.Button(self, text="Quit")
self.quit_button.grid(row=2, column=1)

Slots, Signals and Callbacks

In order to have a responsive GUI, signals emitted from user’s interaction to the trigger the desired action. PySide Widgets come with several pre-defined signals, or it is possible to define your own. Signals can be connected to the desired method using the .connect() method. In Tkinter, buttons have a slot for specifying the action on clicking called command. Here’s how to define button callbacks:

PySideTkinter
self.pause_button.clicked.connect(self.pause)
self.quit_button.clicked.connect(self.quit_click)
self.pause_button.configure(command=self.pause)
self.quit_button.configure(command=self.quit_click)

Updating the appearance of Widgets does not easily lend itself to side-by-side comparison, because PySide allows signals to be emitted from any defined type in Python, whereas Tkinter allows for only 3 variables - StringVar, IntVar and DoubleVar. In PySide, we can create a custom signal called current_datetime,

PySide

class ThreadedClient(QtCore.QThread):
    current_time = QtCore.Signal(datetime)
    ...
    def run(self):
        while True:
            if not self.parent.paused:
                self.current_time.emit(datetime.now())
            sleep(1)

and hook it up to the .update(current_datetime) method:

class GuiPart(QtGui.QWidget):
   def __init__(self):
	...
        self.thread.current_time.connect(self.update)
    ...
    @QtCore.Slot(datetime)
    def update(self, current_datetime):
        percent_elapsed_value = percent_elapsed(current_datetime)
        self.day_bar.setValue(percent_elapsed_value)
        self.day_label.setText(str(percent_elapsed_value))

Note that the update(current_datetime) needs a decorator to describe that the incoming signals are of type datetime.

In Tkinter, Widget attributes must be defined by variables in order to be updated. For example:

Tkinter

class GuiPart(tk.Tk, object):
    def __init__(self):
        ...
        self.day_bar_value = tk.IntVar()
        self.day_label_value = tk.StringVar()
        self.day_bar.configure(variable=self.day_bar_value)
        self.day_label.configure(textvariable=self.day_label_value)
    ...
    def update(self, current_datetime):
        percent_elapsed_value = percent_elapsed(current_datetime)
        self.day_bar_value.set(percent_elapsed_value)
        self.day_label_value.set(str(percent_elapsed_value))

We could use bind to create a custom event to trigger the GUI update, however, it would not communicate the datetime information back to the GUI. Since we kept a reference to the GUI in the ThreadedClient object, we can call the method directly.

class ThreadedClient(threading.Thread):
    ...
    def run(self):
        while True:
            ...
	    if not self.parent.paused:
                self.parent.update(datetime.now())
            sleep(1)

Styling

PySide allows elements to be styled using CSS, and can be applied to the object using setStyleSheet. Like webpage style sheets, styles inherit the style of the encapsulating element unless otherwise specified, so applying the style sheet to the main window will result in the same style sheet being applied to both buttons and the program_label. Tkinter elements can either be styled using the .configure() method, if they were tk widgets, or using ttk.Style() objects. In this example I will style one element using the tk style and the others using ttk.

PySideTkinter
self.setStyleSheet("QWidget { "
                   "background-color: "
                   "\""+cfg.get('colors', 'background')+"\";"
                   "font-family:"+cfg.get('font', 'face')+"; "
                   "font-size: "+cfg.get('font', 'size')+"pt;"
                   "color: \""+cfg.get('colors', 'text')+"\";"
                   "}")
self.day_label.setStyleSheet("QLabel { "
                   "background-color: "
                   "\""+cfg.get('colors', 'text')+"\";"
                   "color: \""+cfg.get('colors', 'sub_text')+"\";"
                   "border: "+cfg.get('layout', 'border_width')+"px"
                   " solid \""+cfg.get('colors', 'border')+"\";"
                   "}")
self.day_bar.setStyleSheet("QProgressBar{ "
                   "background-color: "
	           "\""+cfg.get('colors', 'text')+"\";"
                   "border: "+cfg.get('layout', 'border_width')+"px"
                   " solid \""+cfg.get('colors', 'border')+"\";"
                   " } "
                   "QProgressBar::chunk {    "
                   " background-color: "
                   "\""+cfg.get('colors', 'sub_text')+"\";} ")
self.configure(background=cfg.get('colors', 'background'))
s = ttk.Style()
s.configure('TButton', background=cfg.get('colors', 'background'))
s.configure('TButton', 
            activebackground=cfg.get('colors', 'background'))
s.configure('TButton', foreground=cfg.get('colors', 'text'))
s.configure('TButton', 
            highlightbackground=cfg.get('colors', 'background'))
s.configure('TButton', font=(cfg.get('font', 'face'), 
            int(cfg.get('font', 'size'))))
s.configure('TLabel', background=cfg.get('colors', 'background'))
s.configure('TLabel', foreground=cfg.get('colors', 'text'))
s.configure('TLabel', 
            highlightbackground=cfg.get('colors', 'background'))
s.configure('TLabel', font=(cfg.get('font', 'face'), 
            int(cfg.get('font', 'size'))))
s.configure('Vertical.TProgressbar',  
            background=cfg.get('colors', 'sub_text'))
s.configure('Vertical.TProgressbar',  
            troughcolor=cfg.get('colors', 'text'))
s.configure('Vertical.TProgressbar',  
            highlightbackground=cfg.get('colors', 'border'))
s.configure('Vertical.TProgressbar',  
            highlightthickness=int(cfg.get('layout', 'border_width')))
self.day_label.configure(background=cfg.get('colors', 'text'))
self.day_label.configure(foreground=cfg.get('colors', 'sub_text'))
self.day_label.configure(highlightbackground=
                          cfg.get('colors', 'border'))
self.day_label.configure(highlightthickness=2)
self.day_label.configure(font=(cfg.get('font', 'face'), 
                         int(cfg.get('font', 'size'))))

Filling the screen

PySide spaces elements to fill the entire screen by default, but the main window must be set to fullscreen. Tkinter occupies only the minimal amount of space required by default. To get Tkinter elements to expand, certain rows and columns must be given weight:

PySideTkinter
self.showFullScreen()
self.attributes("-fullscreen", True)
self.columnconfigure(0, weight=1)
self.rowconfigure(0, weight=1)
self.rowconfigure(1, weight=1)

Final Result

After all of that styling, here’s the final result:

PySideTkinter
pyside final resulttkinter final result

Things that make this not quite a fair comparison are:

  • I didn’t bother with stickies in my grid layout in Tkinter, hence the difference in sizes in elements.
  • I was not able to figure out how to change the border of TProgressBar in Tkinter, though it may still be an option I haven’t found
  • text is not aligned to center in the PySide version

Which library should you pick?

Tkinter has the same GNU General Public License as Python. PySide is licensed under the GNU Lesser General Public License. Both of these licenses allow for their use in proprietary software, however the LGPL is a bit friendlier to commercial development than GPL as the requirements to exactly copy the license are less strict. If you’re going to use PySide, the other parts of your code will almost certainly fall under Python’s GPL license, so this point seems moot.

Though both libraries can be employed in object-oriented and procedural fashions, the Tkinter examples tend to use procedural programming and the PySide examples tend to use object-oriented programming, meaning that object-oriented programming neophytes may find PySide intimidating. However, PySide has a greater selection of functionality than Tkinter, in terms of variety of widgets, signals, slots and display options. PySide uses the styles defined in your installed version of Qt, which means that by default PySide programs tend to look less dated than Tkinter programs. PySide is a bit like using LaTeX over Word - though more complicated, the default layout rules are better than Tkinter, allowing design newbs like me to trade technical skill for aesthetics.

In conclusion, my advice would be that Tkinter is good for prototyping simple applications, but if you plan on adding functionality in the future or presenting a polished product, start with PySide.



blog comments powered by Disqus