GUI Template For Python: Part 1

The next problem I needed to solve was to come up with a simple graphical user interface (GUI) template as a front-end for configuring and launching any Python code or module I may wish to write or run. Initial impetus: I didn’t want to have to write a user interface from scratch every time I wrote some Python code to manipulate text, data or files. Bonus reason: if I made the GUI template generic enough, others might be able to use it to create their own user interfaces.

This would solve a problem that occurs in many technical fields. A university professor may have a post-doc researcher on her team, one who has written a complex command line program performing, e.g. image processing, AI or genetic analysis. At some stage, there may be some highly repetitive tests that can be performed by someone less technical, freeing up the researcher. She wouldn’t want him running these repetitive command-line tests with code only he knows how to run or, worse, sitting around designing complex user interfaces for others to use it. It would be better to get an intern or research assistant (or even a temp) to run the tests using a GUI that the researcher can knock up in a day or two. This would free him up to concentrate on his research. And finish it faster.

This post deals with the first part of this problem: how to quickly put together a simple dashboard for Python programs that require the ability to:

      • open and close input and output text files from a GUI,
      • accept input text strings for triggers or search strings,
      • set program flags on/off with check boxes,
      • switch between two or more mutually exclusive program flags using radio boxes, and
      • use the user input to control and restrict access to other widgets and the variables they control.

The next post will extend this by creating a user interface template for command line Python programs that need to input numerical data (with the appropriate type and value checking) and manipulate a large number of input files.

The Problem

The idea was to create a working GUI template easily adaptable as a front end for Python programs lacking user interfaces of their own. It would need to be able to set a program’s options, select input files from anywhere on the hard drive, launch the Python program to do its thing, and then direct the output to a file with a name chosen by me, saving it in the folder of my choice.

But why create a user interface for Python code that I’d written myself? Or for code that could just as easily be launched from a command line in a terminal, or imported as a module into some simple Python wrapper code and run from there? Well, take the simple textual analysis program I described in my last post. What if I wanted to run it on a number of different literary works, changing the testing parameters (my program’s options) for each test?

Each time I ran a test, the command line would change for each iteration:

$ TextualAnalysis.py -i “The Odyssey.txt” -cadfgyHPt > OdysseyAnalysis1.txt
$ TextualAnalysis.py -i "The Odyssey.txt" -s "investigation" -e "Homer, thy song" -cagyHP > OdysseyAnalysis2.txt
$ TextualAnalysis.py -i "The Odyssey.txt" -s "investigation" -e "Homer, thy song" -R 1000 -D MyFancyDictionary.txt -cagyHPt > OdysseyAnalysis3.txt    
$ TextualAnalysis.py -i Ulysses.txt -s “Col Choat” -e “Trieste-Zurich-Paris” -cadfHPt > Ulysses_Analysis.txt

Which is fine for me, as it’s my code and I’m supposed to know how to launch it. But it still might take me half a minute each time to move between text files, to check the spelling of file names and dictionaries, and to paste the correct trigger strings into the command line for each change. And cutting and pasting lines of text into a terminal window is not fun. Nor is it a good use of my time.

Further Uses

And what if someone else wants to use my program? Like, say, an author, who is not too savvy about computers? Before he could use my code, he would need to take the time to read and understand the help information I’ve provided with the -h flag, after I’d explained how to start a terminal window. Then he would have to know how to set multiple flag options from the command line. And he would need to understand my flag dependencies.

Even for my simple command line program, the choice between using either the -d flag (using the program’s own dictionary) and the -D flag (using your own dictionary) is not obvious. I know not to set both at the same time, so I have not written any code to handle what happens when I do. I could easily write the code, but what if I didn’t have to? What if the GUI could not only explain how to use the program, but could also guide the user through the setting of your program options, making sure that nothing was missed, and then launch your underlying code with all the flags set correctly?

The Wish List

What I needed, then, was a generic user interface design template that would allow a specialist programmer (who has written a relatively complex Python program) to put together a user interface relatively quickly for someone else (who is not a programmer) to use his or her program. With minimal GUI skills, the programmer could create an application dashboard for his/her code in no more than a few days. Once finished, it would have the appearance of a moderately sophisticated application that would guide a non-technical user through setting all the program’s options, and then launch the underlying Python code seamlessly.

Once written, it would allow an untrained user to:

      1. ignore the hidden complexity and design of the underlying code,
      2. avoid dealing with any Windows/Linux/OSX command line,
      3. set program options correctly, without knowing about option dependencies,
      4. reconfigure the program options quickly, and
      5. use the program productively as a utility.

The Solution

Tkinter was the most obvious multi-platform solution for Python code. It will run on Windows, Linux or Apple’s OS/X, even if the appearance is not quite the same across all three. And any Python programmer can pick it up in a few days or weeks, without having to go on expensive courses.

With that decision made, I went ahead and created a GUI for the textual analysis program. Once complete, it was generalised into a working, self-explanatory, generic Python front-end which, once understood, can be quickly adapted to serve as a user interface for Python programs that with multiple flag settings that handle text and single files as inputs.

Using this template, here’s what a simple GUI for AnalyseThis.py might look like on different platforms:

Python dashboard running on Mac OS/X (left), Windows 10 (centre) and Linux Mint (right). They may look a little different but the code and behaviour are identical.

 

Note that even though you can use the GUI to explain how your program works, you can also control how much of your program’s functionality your users see. In the dashboards above, not all the program’s options have been offered, e.g. the ability to select searches for archaisms, adverbs and gerunds, or to ignore Roman numerals. In this way,  any option can be enabled or disabled behind the scenes, and the user not given all the choices. Thus a long and potentially confusing list of options – and the full complexity of your underlying code’s features – can be hidden for a particular set of tests. Or different tailored versions of your program presented to different operators to perform different tasks.

Tk’s file handling utilities can select your program’s input file. Note that the users are shown only the files you want them to see, in this case text files.

 

Every widget in every window can be used to guide the user through your software.

 

Once the results of your program are available, they can be saved with a unique name (created by your code) to a dedicated results folder.

How To Install It

First, let’s take a look at the bare Python code for creating the functioning user interface above, with minimum comments:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Creates a front-end GUI for AnalyseThis.py
author: matta_idlecoder at protonmail dot com
"""
from tkinter import Tk, Frame, StringVar, IntVar
from tkinter import NORMAL, DISABLED, RAISED, END
from tkinter import Button, Radiobutton, Checkbutton
from tkinter import Toplevel, Entry, Label
import tkinter.constants, tkinter.filedialog
import tkinter.messagebox
import os
 
from AnalyseThis import analyse_this
 
 
class PythonGUI(Frame):
    """The object space for creating your dashboard and launching your app
   """
    def __init__(self, root):
        """This is where you define & initialise all your GUI variables
        """
        Frame.__init__(self, root)
        self.master = root
 
        self.std_button_opts = {'wraplength':"6c", 'relief':RAISED, 'bd':4, 'pady':5}
 
 
        """ Define the default file options for opening files:
        """
        self.file_opt = {}
        self.file_opt['defaultextension'] = '.txt'
        self.file_opt['filetypes'] = [('text files', '.txt')]
        self.file_opt['initialdir'] = '.'
        self.file_opt['parent'] = root
 
        self.text_name = 'None chosen'
        self.text_path_and_name = None
        self.save_path_and_name = None
 
        self.dict_path_and_name = './EnglishDictionary.txt'
        self.textstatus = StringVar()
        self.textstatus.set('Choose a text file to analyse')
 
        self.dictstatus = StringVar()
        self.dictstatus.set('Change the dictionary')
 
        self.StartTrigButtonName = StringVar()
        self.StopTrigButtonName = StringVar()
        self.StartTriggerDisp = StringVar()
        self.StopTriggerDisp = StringVar()
 
        self.StartTrigger = StringVar()
        self.StopTrigger = StringVar()
 
        self.SortByAlpha = IntVar()
        self.ListContractions = IntVar()
        self.IgnoreAndListPropers = IntVar()
        self.SplitCompoundsAndList = IntVar()
        self.spellcheck = IntVar()
 
        self.SortByAlpha.set(1)
        self.ListContractions.set(1)
        self.IgnoreAndListPropers.set(1)
        self.SplitCompoundsAndList.set(1)
        self.spellcheck.set(1)
 
        self.reset_all_start_trigger_vars()
        self.reset_all_stop_trigger_vars()
 
        self.YourAppDashboard()
        return
 
 
    def YourAppDashboard(self):
        """The function that creates your dashboard and all its widgets
        """
        std_pos_opts = {'fill':tkinter.constants.BOTH, 'padx':10}
 
        BlankLine = ' ' * 80
 
        Button(self, textvariable=self.textstatus, font=('Helvetica', 16),
               command=self.GetNameOfTextToAnalyse,
               **self.std_button_opts).pack(pady=20, **std_pos_opts)
 
        Button(self, textvariable=self.StartTrigButtonName,
               command=self.GetStartTrig, **self.std_button_opts).pack(pady=5,
               **std_pos_opts)
 
        Button(self, textvariable=self.StopTrigButtonName,
               command=self.GetStopTrig, **self.std_button_opts).pack(pady=5,
               **std_pos_opts)
 
        Label(self, text=BlankLine).pack()
 
        Checkbutton(self, text="List proper nouns separately",
                    variable=self.IgnoreAndListPropers).pack(**std_pos_opts)
 
        Checkbutton(self, text='List contractions used',
                    variable=self.ListContractions).pack(**std_pos_opts)
 
        Checkbutton(self, text='List hyphenated compounds\nwords separately',
                    variable=self.SplitCompoundsAndList).pack(**std_pos_opts)
 
        Label(self, text=BlankLine).pack()
 
        Checkbutton(self, text='Do a spell check', variable=self.spellcheck,
                    command=self.onSpellCheck).pack(**std_pos_opts)
 
        self.SelectDictButton = Button(self, text=self.dictstatus,
               textvariable=self.dictstatus, command=self.GetNameOfDictToUse,
               state=NORMAL, **self.std_button_opts)
 
        self.SelectDictButton.pack(**std_pos_opts)
 
        Label(self, text=BlankLine).pack()
 
        Radiobutton(self, text='Sort word frequency analysis\nalphabetically by word',
                    variable=self.SortByAlpha, value=1).\
                    pack(**std_pos_opts)
 
        Radiobutton(self, text='Sort word frequency analysis\nby word frequencies',
                    variable=self.SortByAlpha, value=0).\
                    pack(**std_pos_opts)
 
        Button(self, text='Analyse', command=self.analyse_text, relief=RAISED,
               bd=4, padx=10, pady=5, font=('Helvetica', 16)).pack(padx=10, pady=10)
 
        Button(self, text='Quit', command=self.quit, relief=RAISED,
               bd=4, padx=10, pady=5, font=('Helvetica', 16)).pack(padx=5, pady=10)
        return
 
 
    def GetNameOfTextToAnalyse(self):
        """Returns a filename for your own code to open elsewhere
        """
        if self.text_path_and_name: # a text has been chosen before
            self.file_opt['initialdir'], old_text_name = os.path.split(self.text_path_and_name)
        else:
            old_text_name = self.text_name
            self.file_opt['initialdir'] = '.'
 
        OldTextFilePath = self.text_path_and_name
 
        self.file_opt['title'] = 'Choose a text file to analyse:'
        self.file_opt['initialfile'] = ''
 
        self.text_path_and_name = tkinter.filedialog.askopenfilename(**self.file_opt)
        if self.text_path_and_name:  # if it's not '':
            text_path, self.text_name = os.path.split(self.text_path_and_name)
            self.reset_all_start_trigger_vars()
            self.reset_all_stop_trigger_vars()
        else:
            self.text_path_and_name = OldTextFilePath
            self.text_name = old_text_name
 
        self.textstatus.set('TEXT: ' + self.text_name)
        return
 
 
    def save_as_file(self, result):
        """ Saves the result of your program to a file
        """
        if self.save_path_and_name:
            self.file_opt['initialdir'], save_name = os.path.split(self.save_path_and_name)
 
        OldSaveFilePath = self.save_path_and_name
 
        if "." in self.text_name:
            basename, suffix = self.text_name.split(".")
        else:
            basename = self.text_name
 
        self.file_opt['title'] = "Save results to:"
        self.file_opt['initialfile'] = basename + '.Analysis.txt'
 
        self.save_path_and_name = tkinter.filedialog.asksaveasfilename(**self.file_opt)
        if self.save_path_and_name:  # if it's not '':
            file = open(self.save_path_and_name, 'w')
            file.write(result)
            file.close
            self.file_opt['initialdir'], save_name = os.path.split(self.save_path_and_name)
        else:
            self.save_path_and_name = OldSaveFilePath
        return
 
 
    def GetNameOfDictToUse(self):
        """Called when the user wants to select another dictionary file to use
        """
        OldDictPath = self.dict_path_and_name
        self.file_opt['initialdir'], old_dict_name = os.path.split(OldDictPath)
 
        self.file_opt['title'] = 'Choose a dictionary to use:'
        self.file_opt['initialfile'] = ''
 
        self.dict_path_and_name = tkinter.filedialog.askopenfilename(**self.file_opt)
        if self.dict_path_and_name:
            dict_path, new_dict_name = os.path.split(self.dict_path_and_name)
        else:
            self.dict_path_and_name = OldDictPath
            new_dict_name = old_dict_name
 
        self.dictstatus.set('Dictionary chosen: ' + new_dict_name)
        return
 
 
    def GetStartTrig(self):
        """Opens Entry dialog box to paste start trigger text into
        """
        self.StartTrigWin = Toplevel(self)
        root.grab_release()
        self.StartTrigWin.grab_set()
 
        geom_string = "+{}+{}".format(w-120, h+90)
        self.StartTrigWin.geometry(geom_string)
 
        self.StartTrigWin.title('Set Your Start Trigger')
 
        Instruction = "\nCut and paste a suitable START text trigger from your "
        Instruction += "text file into the space below. Don't worry about "
        Instruction += "putting it in quotation marks. The analysis will only "
        Instruction += "begin AFTER this text string has been found. It need"
        Instruction += " not be long - or unique - but it must be the first "
        Instruction += "time it occurs in the text file, and identify the point "
        Instruction += "after which the actual literary work begins (after the"
        Instruction += " publisher's preamble, forewords, etc):"
 
        Label(self.StartTrigWin, text=Instruction, wraplength="11c").pack(pady=5)
 
        TriggerEntry = Entry(self.StartTrigWin, textvariable=self.StartTriggerDisp,
                             width=80, justify='center')
        TriggerEntry.pack(pady=5)
        TriggerEntry.focus_set()
        TriggerEntry.select_range(0, END)
 
        button_1_title = "START analysing my text file only after finding the "
        button_1_title += "above text trigger."
        Button(self.StartTrigWin, text=button_1_title,
               command=self.onStartButton1,
               **self.std_button_opts).pack(pady=5)
 
        button_2_title = "Don't worry about using a start trigger. Start "
        button_2_title += "analysing my text file from the BEGINNING."
        Button(self.StartTrigWin, text=button_2_title,
               command=self.onStartButton2,
               **self.std_button_opts).pack(pady=5)
        return
 
 
    def onStartButton1(self):
        """This is called when the user wants to save the pasted start trigger
        """
        if (self.StartTriggerDisp.get()[:3] == '===')  or \
            (len(self.StartTriggerDisp.get()) == 0):
            self.reset_all_start_trigger_vars()
        else:
            self.StartTrigger.set(self.StartTriggerDisp.get())
            self.StartTrigButtonName.set('START trigger is SET')
 
        self.StartTrigWin.destroy()
        return
 
 
    def onStartButton2(self):
        """Called when the user decides not to save the pasted start trigger
        """
        self.reset_all_start_trigger_vars()
        self.StartTrigWin.destroy()
        return
 
 
    def reset_all_start_trigger_vars(self):
        """Resets all variables related to the start triggers
        """
        self.StartTrigButtonName.set('No start trigger is set: analyse from the BEGINNING')
        self.StartTriggerDisp.set('=== COPY & PASTE YOUR START TRIGGER TEXT HERE ===')
        self.StartTrigger.set("")
        return
 
 
    def GetStopTrig(self):
        """Opens an Entry dialog box to paste the stop trigger text into
        """
        self.StopTrigWin = Toplevel(self)
        root.grab_release()
        self.StopTrigWin.grab_set()
 
        geom_string = "+{}+{}".format(w-120, h+150)
        self.StopTrigWin.geometry(geom_string)
 
        self.StopTrigWin.title('Set Your Stop Trigger')
 
        Instruction = "\nCut and paste a suitable STOP text trigger from your "
        Instruction += "text file into the space below. The analysis will stop "
        Instruction += "when this text is found. It need not be long, but it "
        Instruction += "must be the first time it occurs in the text file, and "
        Instruction += "identify the end of the actual literary work, before "
        Instruction += "the index, glossary, etc. It will not be part of the "
        Instruction += "analysis. Don't worry about putting it in quotation marks:"
 
        Label(self.StopTrigWin, text=Instruction, wraplength="11c").pack(pady=5)
 
        TriggerEntry = Entry(self.StopTrigWin,
                                  textvariable=self.StopTriggerDisp,
                                  width=80, justify='center')
        TriggerEntry.pack(pady=5)
        TriggerEntry.focus_set()
        TriggerEntry.select_range(0, END)
 
        button_1_title = "STOP analysing my text file when the the above STOP "
        button_1_title += "text trigger is found."
        Button(self.StopTrigWin, text=button_1_title,
               command=self.onStopButton1, **self.std_button_opts).pack(pady=5)
 
        button_2_title = "Don't worry about using a stop trigger. Analyse my"
        button_2_title += "text file all the way to the END."
        Button(self.StopTrigWin, text=button_2_title,
               command=self.onStopButton2,
               **self.std_button_opts).pack(pady=5)
        return
 
 
    def onStopButton1(self):
        """This is called when the user wants to save the pasted stop trigger
        """
        if (self.StopTriggerDisp.get()[:3] == '===') or \
            (len(self.StopTriggerDisp.get()) == 0):
            self.reset_all_stop_trigger_vars()
        else:
            self.StopTrigger.set(self.StopTriggerDisp.get())
            self.StopTrigButtonName.set('STOP trigger is SET')
 
        self.StopTrigWin.destroy()
        return
 
 
    def onStopButton2(self):
        """Called when the user decides not to save the pasted stop trigger
        """
        self.reset_all_stop_trigger_vars()
        self.StopTrigWin.destroy()
        return
 
 
    def reset_all_stop_trigger_vars(self):
        """Resets all variables related to the stop triggers
        """
        self.StopTrigButtonName.set('No stop trigger is set: analyse all the way to the END')
        self.StopTriggerDisp.set('=== COPY & PASTE YOUR STOP TRIGGER TEXT HERE ===')
        self.StopTrigger.set("")
        return
 
 
    def onSpellCheck(self):
        """Enables/disables dictionary selector button
        """
        if self.spellcheck.get():
            self.SelectDictButton.config(state=NORMAL)
        else:
            self.SelectDictButton.config(state=DISABLED)
        return
 
 
    def analyse_text(self):
        """A simple wrapper to pass your dashboard-set variables to your logic
        """
        result = ''
 
        if self.text_name == 'None chosen':
            tkinter.messagebox.showinfo(title='Error: no text file chosen.   ',
                message="Please choose a text to analyse.", icon='error')
 
        else:
            result = analyse_this(self.text_path_and_name,
                        self.dict_path_and_name,
                        SortByAlpha=self.SortByAlpha.get(),
                        StartTrigger=self.StartTrigger.get(),
                        StopTrigger=self.StopTrigger.get(),
                        CheckingSpelling=self.spellcheck.get(),
                        SplitAndListCompounds=self.SplitCompoundsAndList.get(),
                        IgnoreNListProperNouns=self.IgnoreAndListPropers.get(),
                        ListingContractions=self.ListContractions.get(),
                        ListingAdverbs=False,
                        ListingGerunds=False)
 
            self.save_as_file(result)
        return
 
 
if __name__=='__main__':
    root = Tk()
 
    HalfScreenWidth = int(root.winfo_screenwidth()/2)
    HalfScreenHeight = int(root.winfo_screenheight()/2)
 
    w = HalfScreenWidth - 200
    h = HalfScreenHeight - 350
    # Centre window:
    geom_string = "+{}+{}".format(w, h)
 
    root.geometry(geom_string)
    root.title('Dashboard')
    root.lift()
    PythonGUI(root).pack()
 
    root.mainloop()

 

To install it, copy the above code to the same folder as the program AnalyseThis.py from the previous post. This is a specific textual analyser GUI – the general template will come later.  Make sure you also copy the new and improved version of AnalyseThis.py (the code has been tidied up, and some bugs fixed). Your analysis folder listing should now look like this:

AnalyseThis.py
EnglishDictionary.txt
TextualAnalysis_GUI.py
The Odyssey.txt

You can copy your input files into this folder (I’ve shown The Odyssey), but they don’t have to be here (see below for why).  To launch the user interface, all you need to do is start a terminal window, change your directory to your analysis folder above, and type:

$ python3 ./TextualAnalysis_GUI.py

(Or whatever works on your system – see previous post for possible variations. If you’re a Python programmer, you’ll know how to launch Python code from the command line already, so I won’t bore you by explaining how to do it. Just remember to change the file permission to executable. )

Fast Python GUI Template

You’re now ready to create a front-end for your own program. Quickly scan through the code listing given below. It’s the same program as before, but with the principle GUI program now called PythonGUI.py. The listing also now contains detailed comments explaining what each part does, and how to adapt it for your own code.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Simple graphical user interface (GUI) template for use as a front-end for
configuring and launching any Python code that manipulates text and files from
the command line.
 
Designed for fast development of user interfaces for Python programs that
read and manipulate text from a small number of input files.
 
Please read the comments and follow the instructions to adapt it for launching
your own code.
 
author: matta_idlecoder at protonmail dot com
"""
 
"""All imported tkinter functions and variables have been named.
The first four lines could all have been replaced by:
 
    from tkinter import *
 
This way, you keep track of the source of your functions, and have an
explicit inventory of what is being imported from where, which is useful for
porting to other GUI systems later, should you ever want to do so:
"""
from tkinter import Tk, Frame, StringVar, IntVar
from tkinter import NORMAL, DISABLED, RAISED, END
from tkinter import Button, Radiobutton, Checkbutton
from tkinter import Toplevel, Entry, Label
import tkinter.constants, tkinter.filedialog
import tkinter.messagebox
import os
 
"""This is where you import your own underlying logic file as a Python module
and name your functions you will be calling. It's a shorter way of saying:
"from AnalyseThis.py import analyse_this() and all the functions it uses"
"""
from AnalyseThis import analyse_this
 
 
class PythonGUI(Frame):
    """The object space for creating your dashboard and launching your app
 
    Within this class, every function and widget can see every other's
    self.variables. In this way, the self-space scope within a class can be
    thought of as halfway between local and global.
    """
    def __init__(self, root):
        """This is where you define & initialise all your GUI variables
        """
        Frame.__init__(self, root)
        self.master = root
 
        # define the default button appearance: wrap any text to 6cm wide block:
        self.std_button_opts = {'wraplength':"6c", 'relief':RAISED, 'bd':4, 'pady':5}
 
 
        """ Define the default file options for opening files:
        """
        self.file_opt = {}
 
        """this is appended to any filename the user offers as a filename,
        if they forget to add an extension: """
        self.file_opt['defaultextension'] = '.txt'
 
        """ This can be set to a default filename. Here, I'm just using it
        as a reminder to the user :
        self.file_opt['initialfile'] = '*.txt'
        """
 
        """ This is where you tell tkinter what type of files you want to show
        the user when they open a tkinter file open/save window, 'e.g:
        self.file_opt['filetypes'] =  [('PNG files', '*.png'),
        ('JPEG files', '*.jpg'), ('PDF files', '*.pdf')]
        """
        self.file_opt['filetypes'] = [('text files', '.txt')]
 
        """ '.' is the current folder, i.e. where this prog was launched from
        If you have different folders for input and output files, redefine
        this variable in each file open/save function that is called:
        """
        self.file_opt['initialdir'] = '.'
 
        # the window to disable when the file open/save window opens on top of it:
        self.file_opt['parent'] = root
 
        self.text_name = 'None chosen'
        self.text_path_and_name = None
        self.save_path_and_name = None
 
        self.dict_path_and_name = './EnglishDictionary.txt'
        """ ^^^ This is the line where you would define a separate
        default folder for your config files, should you decide to keep
        input, config and output files apart. Make sure the folder exists
        before you run the code. e.g:
        self.dict_path_and_name = '../ConfigFiles/ConfigSettings.txt'
        """
 
 
        """ Define and initialise any info status variables here that will
        be set and displayed by widgets:
        """
        # default text strings to be displayed on buttons:
        self.textstatus = StringVar()
        self.textstatus.set('Choose a text file to analyse')
 
        self.dictstatus = StringVar()
        self.dictstatus.set('Change the dictionary')
 
        self.StartTrigButtonName = StringVar()
        self.StopTrigButtonName = StringVar()
        self.StartTriggerDisp = StringVar()
        self.StopTriggerDisp = StringVar()
 
 
        """ Define and initialise all the variables here you will want to pass
        to your underlying logic after setting them in your GUI. They must be
        defined in tkinter terms of StringVar(), IntVar() and DoubleVar(). Note
        that there is no Boolean() type. Is this is required, use IntVar() and
        set it to 0 or 1. All types can also be set to None if not set:
        """
        self.StartTrigger = StringVar()
        self.StopTrigger = StringVar()
 
        self.SortByAlpha = IntVar()
        self.ListContractions = IntVar()
        self.IgnoreAndListPropers = IntVar()
        self.SplitCompoundsAndList = IntVar()
        self.spellcheck = IntVar()
 
        self.SortByAlpha.set(1)
        self.ListContractions.set(1)
        self.IgnoreAndListPropers.set(1)
        self.SplitCompoundsAndList.set(1)
        self.spellcheck.set(1)
 
        # initialise all string variables that will need reset when the
        # input file changes:
        self.reset_all_start_trigger_vars()
        self.reset_all_stop_trigger_vars()
 
        # Call your dashboard:
        self.YourAppDashboard()
        return
 
 
    def YourAppDashboard(self):
        """The function that creates your dashboard and all its widgets
        """
 
        # default options for widgets on this dashboard:
        std_pos_opts = {'fill':tkinter.constants.BOTH, 'padx':10}
 
        BlankLine = ' ' * 80  # Simple way to space things out
 
        """ This is a basic GUI fetch filename widget, calling on a specific
        function that knows what kind of filename to look for.
 
        self.textstatus = what will be displayed on the Button
 
        self.textvariable = the variable that will be set by the user
        interacting with the button.
 
        In this case they are the same, which has the effect that the button
        can be used to show the name of the file ] you pick. Note that it is
        just a name. The file will be opened later by your underlying logic:
        """
        Button(self, textvariable=self.textstatus, font=('Helvetica', 16),
               command=self.GetNameOfTextToAnalyse,
               **self.std_button_opts).pack(pady=20, **std_pos_opts)
 
        """ Two simple widgets for asking the user for text strings. Most of
        the complexity is beneath the hood in the commands they call:
        """
        Button(self, textvariable=self.StartTrigButtonName,
               command=self.GetStartTrig, **self.std_button_opts).pack(pady=5,
               **std_pos_opts)
 
        Button(self, textvariable=self.StopTrigButtonName,
               command=self.GetStopTrig, **self.std_button_opts).pack(pady=5,
               **std_pos_opts)
 
        Label(self, text=BlankLine).pack()
 
        """These three are all do the same thing. Each defines a checkbutton,
        and the variable that will be set to either 0 or 1 by the user
        clicking on the Checkbutton:
        """
        Checkbutton(self, text="List proper nouns separately",
                    variable=self.IgnoreAndListPropers).pack(**std_pos_opts)
 
        Checkbutton(self, text='List contractions used',
                    variable=self.ListContractions).pack(**std_pos_opts)
 
        Checkbutton(self, text='List hyphenated compounds\nwords separately',
                    variable=self.SplitCompoundsAndList).pack(**std_pos_opts)
 
        Label(self, text=BlankLine).pack()
 
        """This widget uses the value of self.spellcheck to control whether the
        widget bwlow it is enabled or disabled, i.e whether the user can click
        on it or not. If the user disables the spellcheck, he/she isn't going
        to need a dictionary. Each time this Checkbutton widget is checked or
        unchecked, the handler function self.onSpellCheck() above looks at the
        new value of the variable self.spellcheck and either enables or
        disables the widget self.SelectDictButton below: """
        Checkbutton(self, text='Do a spell check', variable=self.spellcheck,
                    command=self.onSpellCheck).pack(**std_pos_opts)
 
        """ This is the same basic fetch filename and path widget that was used
        to call self.GetNameOfTextToAnalyse at the top of this function. This
        time it calls self.GetNameOfDictToUse to allow the user to select a
        different dictionary. Its default state is NORMAL, which means
        'enabled'. The handler self.onSpellCheck called by the above widget
        will enable or disable this widget, depending on the value of
        self.spellcheck: """
        self.SelectDictButton = Button(self, text=self.dictstatus,
               textvariable=self.dictstatus, command=self.GetNameOfDictToUse,
               state=NORMAL, **self.std_button_opts)
 
        """When giving a widget a name (to allow its configuration options to
        be set elsewhere) you need to separate the Button definition above
        from its geometry drawing command (i.e. pack, grid or place) so that
        the name you assign to it (in this case self.SelectDictButton=) is
        not None: """
        self.SelectDictButton.pack(**std_pos_opts)
 
        Label(self, text=BlankLine).pack()
 
        """These two radiobuttons are connected by the same variable, in this
        case SortByAlpha. However, each widget will set SortByAlpha to a
        different value. Since the Radiobuttons also reflect the current value,
        by selecting one, you therefore de-select the other:
        """
        Radiobutton(self, text='Sort word frequency analysis\nalphabetically by word',
                    variable=self.SortByAlpha, value=1).\
                    pack(**std_pos_opts)
 
        Radiobutton(self, text='Sort word frequency analysis\nby word frequencies',
                    variable=self.SortByAlpha, value=0).\
                    pack(**std_pos_opts)
 
        # This button calls a handler function that calls your underlying logic
        Button(self, text='Analyse', command=self.run_my_code, relief=RAISED,
               bd=4, padx=10, pady=5, font=('Helvetica', 16)).pack(padx=10, pady=10)
 
        """ self.quit is a built-in tkinter function defined as "Quit the
        interpreter. All widgets will be destroyed." Resistance is futile:
        """
        Button(self, text='Quit', command=self.quit, relief=RAISED,
               bd=4, padx=10, pady=5, font=('Helvetica', 16)).pack(padx=5, pady=10)
        return
 
 
    def GetNameOfTextToAnalyse(self):
        """Returns a filename for your own code to open elsewhere
        """
        if self.text_path_and_name: # a text has been chosen before
            self.file_opt['initialdir'], old_text_name = os.path.split(self.text_path_and_name)
        else:
            old_text_name = self.text_name
            self.file_opt['initialdir'] = '.'
            """ This is where you would define a separate default folder for
            your input text files, should you decide to keep input, config and
            output files apart. Make sure the folder exists before you run the
            code:
            self.file_opt['initialdir'] = '../Input Files'
            """
 
        OldTextFilePath = self.text_path_and_name
 
        # Define what the user will see in the open file window:
        self.file_opt['title'] = 'Choose a text file to analyse:'
        self.file_opt['initialfile'] = ''
 
        # returns a file path and name, or '':
        self.text_path_and_name = tkinter.filedialog.askopenfilename(**self.file_opt)
        if self.text_path_and_name:  # if it's not '':
            # User didn't hit cancel:
            text_path, self.text_name = os.path.split(self.text_path_and_name)
            self.reset_all_start_trigger_vars()
            self.reset_all_stop_trigger_vars()
        else:
            # User hit cancel. Reset path to last value:
            self.text_path_and_name = OldTextFilePath
            self.text_name = old_text_name
 
        self.textstatus.set('TEXT: ' + self.text_name)
        return
 
 
    def save_as_file(self, result):
        """ Saves the result of your program to a file
        """
        if self.save_path_and_name:
            # a file has been saved before. Go to the same place:
            self.file_opt['initialdir'], save_name = os.path.split(self.save_path_and_name)
        #else:
            """ This is where you would define a separate default folder for
            your output files, should you decide to keep input, config and
            output files apart. Make sure the folder exists before you run the
            code.
            self.file_opt['initialdir'] = '../Output Files'
            """
 
        OldSaveFilePath = self.save_path_and_name
 
        # create the name of your results file:
        if "." in self.text_name:
            basename, suffix = self.text_name.split(".")
        else:
            # Just in case the text chosen does not have a .txt suffix:
            basename = self.text_name
 
        # Define what the user will see in the save file window:
        self.file_opt['title'] = "Save results to:"
        self.file_opt['initialfile'] = basename + '.Analysis.txt'
 
        # returns a file path and name, or '':
        self.save_path_and_name = tkinter.filedialog.asksaveasfilename(**self.file_opt)
        if self.save_path_and_name:  # if it's not '':
            # user didn't hit cancel:
            file = open(self.save_path_and_name, 'w')
            file.write(result)
            file.close
            self.file_opt['initialdir'], save_name = os.path.split(self.save_path_and_name)
        else:
            # User hit cancel. Reset path to last value:
            self.save_path_and_name = OldSaveFilePath
        return
 
 
    def GetNameOfDictToUse(self):
        """Called when the user wants to select another dictionary file to use
        """
        OldDictPath = self.dict_path_and_name
        self.file_opt['initialdir'], old_dict_name = os.path.split(OldDictPath)
 
        # Define what the user will see in the choose config file window:
        self.file_opt['title'] = 'Choose a dictionary to use:'
        self.file_opt['initialfile'] = ''
 
        # returns a file path and name, or '':
        self.dict_path_and_name = tkinter.filedialog.askopenfilename(**self.file_opt)
        if self.dict_path_and_name:
            # User didn't hit cancel:
            dict_path, new_dict_name = os.path.split(self.dict_path_and_name)
        else:
            # User hit cancel. Reset path to dictionary to last value:
            self.dict_path_and_name = OldDictPath
            new_dict_name = old_dict_name
 
        self.dictstatus.set('Dictionary chosen: ' + new_dict_name)
        return
 
 
    def GetStartTrig(self):
        """Opens Entry dialog box to paste start trigger text into
        """
        self.StartTrigWin = Toplevel(self)
        root.grab_release()
        self.StartTrigWin.grab_set()
 
        # This should almost line up the sub-windows up with the buttons that
        # called them, but may need adjusting for different platforms/screens:
        geom_string = "+{}+{}".format(w-120, h+90)
        self.StartTrigWin.geometry(geom_string)
 
        self.StartTrigWin.title('Set Your Start Trigger')
 
        Instruction = "\nCut and paste a suitable START text trigger from your "
        Instruction += "text file into the space below. Don't worry about "
        Instruction += "putting it in quotation marks. The analysis will only "
        Instruction += "begin AFTER this text string has been found. It need"
        Instruction += " not be long - or unique - but it must be the first "
        Instruction += "time it occurs in the text file, and identify the point "
        Instruction += "after which the actual literary work begins (after the"
        Instruction += " publisher's preamble, forewords, etc):"
 
        # Wrap the above text into an 11cm wide paragraph:
        Label(self.StartTrigWin, text=Instruction, wraplength="11c").pack(pady=5)
 
        TriggerEntry = Entry(self.StartTrigWin, textvariable=self.StartTriggerDisp,
                             width=80, justify='center')
        TriggerEntry.pack(pady=5)
        TriggerEntry.focus_set()  # make the Entry box the active widget
        TriggerEntry.select_range(0, END)  # highlight all the text
 
        button_1_title = "START analysing my text file only after finding the "
        button_1_title += "above text trigger."
        Button(self.StartTrigWin, text=button_1_title,
               command=self.onStartButton1,
               **self.std_button_opts).pack(pady=5)
 
        button_2_title = "Don't worry about using a start trigger. Start "
        button_2_title += "analysing my text file from the BEGINNING."
        Button(self.StartTrigWin, text=button_2_title,
               command=self.onStartButton2,
               **self.std_button_opts).pack(pady=5)
        return
 
 
    def onStartButton1(self):
        """This is called when the user wants to save the pasted start trigger
        """
        if (self.StartTriggerDisp.get()[:3] == '===')  or \
            (len(self.StartTriggerDisp.get()) == 0):
            self.reset_all_start_trigger_vars()
        else:
            # Newline forces button to 2 lines, which maintains the layout:
            self.StartTrigger.set(self.StartTriggerDisp.get())
            self.StartTrigButtonName.set('START trigger is SET')
 
        self.StartTrigWin.destroy()
        return
 
 
    def onStartButton2(self):
        """Called when the user decides not to save the pasted start trigger
        """
        self.reset_all_start_trigger_vars()
        self.StartTrigWin.destroy()
        return
 
 
    def reset_all_start_trigger_vars(self):
        """Resets all variables related to the start triggers
        """
        self.StartTrigButtonName.set('No start trigger is set: analyse from the BEGINNING')
        self.StartTriggerDisp.set('=== COPY & PASTE YOUR START TRIGGER TEXT HERE ===')
        self.StartTrigger.set("")
        return
 
 
    def GetStopTrig(self):
        """Opens an Entry dialog box to paste the stop trigger text into
        """
        self.StopTrigWin = Toplevel(self)
        root.grab_release()
        self.StopTrigWin.grab_set()
 
        # This should almost line up the sub-windows up with the buttons that
        # called them, but may need adjusting for different platforms/screens:
        geom_string = "+{}+{}".format(w-120, h+150)
        self.StopTrigWin.geometry(geom_string)
 
        self.StopTrigWin.title('Set Your Stop Trigger')
 
        Instruction = "\nCut and paste a suitable STOP text trigger from your "
        Instruction += "text file into the space below. The analysis will stop "
        Instruction += "when this text is found. It need not be long, but it "
        Instruction += "must be the first time it occurs in the text file, and "
        Instruction += "identify the end of the actual literary work, before "
        Instruction += "the index, glossary, etc. It will not be part of the "
        Instruction += "analysis. Don't worry about putting it in quotation marks:"
 
        # Wrap the above text into an 11cm wide block:
        Label(self.StopTrigWin, text=Instruction, wraplength="11c").pack(pady=5)
 
        TriggerEntry = Entry(self.StopTrigWin,
                                  textvariable=self.StopTriggerDisp,
                                  width=80, justify='center')
        TriggerEntry.pack(pady=5)
        TriggerEntry.focus_set()  # make the Entry box the active widget
        TriggerEntry.select_range(0, END)  # highlight all the text
 
        button_1_title = "STOP analysing my text file when the the above STOP "
        button_1_title += "text trigger is found."
        Button(self.StopTrigWin, text=button_1_title,
               command=self.onStopButton1, **self.std_button_opts).pack(pady=5)
 
        button_2_title = "Don't worry about using a stop trigger. Analyse my"
        button_2_title += "text file all the way to the END."
        Button(self.StopTrigWin, text=button_2_title,
               command=self.onStopButton2,
               **self.std_button_opts).pack(pady=5)
        return
 
 
    def onStopButton1(self):
        """This is called when the user wants to save the pasted stop trigger
        """
        if (self.StopTriggerDisp.get()[:3] == '===') or \
            (len(self.StopTriggerDisp.get()) == 0):
            self.reset_all_stop_trigger_vars()
        else:
            # Newline forces button to 2 lines, which maintains the layout:
            self.StopTrigger.set(self.StopTriggerDisp.get())
            self.StopTrigButtonName.set('STOP trigger is SET')
 
        self.StopTrigWin.destroy()
        return
 
 
    def onStopButton2(self):
        """Called when the user decides not to save the pasted stop trigger
        """
        self.reset_all_stop_trigger_vars()
        self.StopTrigWin.destroy()
        return
 
 
    def reset_all_stop_trigger_vars(self):
        """Resets all variables related to the stop triggers
        """
        self.StopTrigButtonName.set('No stop trigger is set: analyse all the way to the END')
        self.StopTriggerDisp.set('=== COPY & PASTE YOUR STOP TRIGGER TEXT HERE ===')
        self.StopTrigger.set("")
        return
 
 
    def onSpellCheck(self):
        """Enables/disables dictionary selector button
        """
        if self.spellcheck.get():
            self.SelectDictButton.config(state=NORMAL)
        else:
            self.SelectDictButton.config(state=DISABLED)
        return
 
 
    def run_my_code(self):
        """A simple wrapper to pass your dashboard-set variables to your logic
        """
        result = ''
 
        if self.text_name == 'None chosen':
            tkinter.messagebox.showinfo(title='Error: no text file chosen.   ',
                message="Please choose a text to analyse.", icon='error')
 
        else:
            """You don't explicitly have to set and pass EVERY variable to your
            logic. Some can be preset here, while others by omission can assume
            their defaults as set in your logic's function definition. This
            makes your GUI tidier by omitting both types from your dashboard.
            For example, here the optional kwargs ListingArchaisms and
            RemovingRomans have assumed their defaults of True (by not being
            named). On the other hand, the kwargs ListingAdverbs and
            ListingGerunds have been explicitly disabled here, over-ruling
            their defined defaults:
            """
            result = analyse_this(self.text_path_and_name,
                        self.dict_path_and_name,
                        SortByAlpha=self.SortByAlpha.get(),
                        StartTrigger=self.StartTrigger.get(),
                        StopTrigger=self.StopTrigger.get(),
                        CheckingSpelling=self.spellcheck.get(),
                        SplitAndListCompounds=self.SplitCompoundsAndList.get(),
                        IgnoreNListProperNouns=self.IgnoreAndListPropers.get(),
                        ListingContractions=self.ListContractions.get(),
                        ListingAdverbs=False,
                        ListingGerunds=False)
 
            self.save_as_file(result)
        return  # control to your dashboard
 
 
if __name__=='__main__':
    root = Tk()
 
    HalfScreenWidth = int(root.winfo_screenwidth()/2)
    HalfScreenHeight = int(root.winfo_screenheight()/2)
 
    w = HalfScreenWidth - 200
    h = HalfScreenHeight - 350
    # Centre window:
    geom_string = "+{}+{}".format(w, h)
 
    root.geometry(geom_string)
    root.title('Dashboard')
    root.lift()
    PythonGUI(root).pack()
 
    root.mainloop()

 

The Tkinter geometry I’ve used is pack(), which stacks the buttons, check boxes and radio buttons in one column from top to bottom. If you want to create a more complex dashboard interface for your program, one with more than one widget to a row (i.e. one with widgets laid out in a grid of rows and columns) you’re going to have to use grid(). (1) This will be introduced in the next post.

If your own program is likely to create a lot of results files, it will be important to keep track of them. You should consider creating one folder for the files you want to analyse, another for the supporting data files you may need to test them with, and another for all your test results (for this textual analysis program, these would be folders called something like EnglishTexts, Dictionaries, TextualAnalysisResults, respectively). You can navigate to these folders every time you start up your GUI and it will remember the different locations for each iteration. But once you hit Quit it will forget them.  A better way is to pre-set these folders in your code. The comments in the template listing show you where and how to do this.

Your principal function in your back-end Python logic will either have to return its results to self.run_my_code() to be saved to a file, or to save its output to a file itself. The code above deals with the former method: the back-end function has returned the results to the calling function self.run_my_code(), which has handled the save. If your code knows how to save its results to a file, it’s probably a good idea to use your GUI code to get the filename from the user, and to pass it to your back-end logic to handle to save.

It’s also good idea to think about auto-generating your output filenames automatically from the input filenames, possible combined with some kind of incremented identifier or the date. If that’s not specific enough, another way is to pass the user-selected variables to self.save_as_file(), and let this function generate some result-specific filenames that encode the settings used to generate the results. This way, a summary of how the contents were generated can be seen by simply glancing at the filenames. And this would go even further towards simplifying the user’s job.

The goal should be to leave your back-end logic alone, so that you simply call your principle function from self.run_my_code() as part of a module. But be prepared to change your code a little. You’ll maybe have to eliminate a lot of global variables (always a good idea) so that everything is passed explicitly between your functions. This might create a long list of variables in your function calls, but at least you’ll know what everything is and where it is defined.

For clarity, I’d recommend using as many kwargs (optional key word arguments) as possible in your principal function definition (for non-essential variables) for at least three reasons:

      1. it makes them optional in the function call, which is exactly what you want if you’re planning on having several simultaneous functional versions of your GUI for different users and/or analysis situations.
      2. You can set some variables to their default values, which they assume when you don’t mention them in your function call.
      3. You can pre-set them to non-default values when you call your function, making the function call code infinitely more readable (and maintainable, by you in six months!) to say, e.g:
your_function(map_output=True, colour=False, high_res=True, navigating=False)

instead of the more cryptic:

your_function(True, False, True, False)

as you might do if your variables were passed positionally, forcing you to look up your own function definition to see what’s happening. Of course, you can create meaningfully named local variables and pass them by position, but you would need more lines of code to initialise them, and lose the advantage of optional variables with defaults.

If you want to make your GUI dashboard easier to launch for a non-techy user, you can create a shortcut to launch it from your desktop. On Windows 10 you simply make a shortcut to it on your desktop, and it will launch the GUI with a double click (and offer a terminal window, which you tell your user to ignore). On Mac and Linux, there are similar tricks.

If you’ve done all of the above and you’re still not sure what is happening, a great introductory tutorial on using Tkinter with Python for layout management can be found here. It’s a good idea to check out their whole tutorial anyway, even just to gain an understanding of what other widgets are available, and how you may be able to use them in your own GUI.

 

In Summary

Potential issues   (a) The learning curve for basic competency in Tkinter might be a week or two, even for experienced Python programmers. Event-driven GUI code has a different program flow to conventional sequential programs, and requires a different way of thinking.

(b) Tkinter widgets do not always look exactly the same on Linux, Mac OS/X and Windows, especially if the versions of Tkinter are out of sync. As shown earlier, there are some stylistic differences between how Tkinter is implemented on each platform, but they are nothing to worry about. I’ve tested the code on Windows 10, Linux Mint and Mac OS/X and it works fine. They’re close enough for you not to care about the hardware that is running underneath.

Typical time to create your own Python GUI: probably a few days for the first one, depending on the complexity of your underlying code and how well you know its options. Once you’ve written and debugged your first dashboard you’ll get faster at doing each one. Eventually, you’ll be able to put together your own tailored GUIs for different users/tests in only a few hours.

Platform portability: Windows, Linux or Mac OS/X. And you’ll need Python 3 and Tkinter (note that Python 3 imports tkinter in lower case).

Maintainability:  Once you’ve created your first interface, relatively easy for a competent Python programmer. Just remember that sometimes it’s better to do something in two or three clearly commented lines, even though you know it can be done in a single, beautifully elegant, Pythonesque line that makes absolutely no sense in six months.

As always, feel free to copy, adapt and improve this template for your own projects.

 

Notes

(1) The Tkinter geometry options are (in ascending order of configurability and complexity) pack(), grid() and place(). The default for pack() is to position your widgets in a stack, one below the other, as I’ve done here, which is fine for most simple dashboards. Since the goal here was to create a GUI template that anyone could use to create dashboards quickly, pack() was the geometry chosen.

grid() works in almost the same way in laying out your widgets, but in two dimensions. Simply decide how many rows and columns you want in your dashboard and then tell grid() where you want each widget to go,  e.g:

Entry(window_name, variables, options).grid(row=0, column=3).

and Tk will work it out. If you feel like using grid() instead of pack(), an excellent manual has been written by John W. Shipman at New Mexico Tech and is available here.

The geometry that offers the most configurability and accuracy is place(), which uses screen pixel coordinates, but it can get very complex and is not ideal for a fast development cycle, which is what I was going for here. If you need that kind of accuracy and you’re willing to spend the time learning how to get it, you might as well spend the time learning PyQt, or something similar.  Whichever Tk geometry you choose you need to stick to it, and always obey what I feel should be the Tkinter golden rule: never mix two different Tk geometries in the same Tk window.

 

 

Leave a Reply

Your email address will not be published. Required fields are marked *