GUI Template For Python: Part 2

Tkinter dashboards for a Markov random text generator implemented in Windows, OS/X and Linux

This is the second of two posts on how to quickly create a Tkinter dashboard for your command line Python programs. The Tkinter widgets and programming techniques it introduces are a sequel to the previous post.

So far, you have an interactive graphical way of opening a file to analyse it in some way with your own logic, entering text to use as triggers or search strings, setting your own program flags on/off using check boxes, switching between two or more mutually exclusive program flags using radio boxes, controlling access to widgets and the variables they control, calling your own logic, and saving your results in a new file.

This post will build on these skills by showing how to create a dashboard to accept numerical input, perform different kinds of type- and value-checking, and select multiple input files simultaneously using a Tkinter GUI file selector. The solution will be multi-platform, and is shown running above on (from left) Windows 10, Mac OS/X and Linux Mint above. This post will explain how to create the same thing for your own program. Before proceeding, make sure you’ve read and understood the previous post.

The Back-End Code

The back-end logic I’ve chosen to develop this GUI for is a fun, command line Python program I recently wrote to create nonsense Markov word-chains from examples of literature provided. The program then uses the dictionary of word-chains it has found to create random sentences that are often grammatically correct but actually nonsense. The program is normally launched from the command line using lines such as the one below:

$ Markov.py –o2 –w200 -s “wise Odysseus" Odyssey.txt Ulysses.txt Ivanhoe.txt > Markov.txt

The key to the command line is as follows:

      • –o2 is telling it to use a Markov word chain order of two.
      • –w200 is telling it to create a paragraph of nonsense that is 200 words long.
      • –s “wise Odysseus” is telling the program that the paragraph of meaningless text must start with these opening words.
      • The next three text file names are telling it to create the random text by mixing up the second order word-chains it finds in The Odyssey, Ulysses and Ivanhoe.

If everything is typed correctly, and the command line is internally consistent, here is what an amusing second order output might look like:

The following text(s) will now be analysed to create a second order Markov word-chain dictionary:
 
 *  Ulysses.txt 
 *  A Tale of Two Cities.txt 
 *  Huckleberry Finn.txt 
 *  Ivanhoe.txt 
 *  Macbeth.txt 
 *  Odyssey.txt 
 
 Getting second order word chains from: Ulysses.txt
 Getting second order word chains from: A Tale of Two Cities.txt
 Getting second order word chains from: Huckleberry Finn.txt
 Getting second order word chains from: Ivanhoe.txt
 Getting second order word chains from: Macbeth.txt
 Getting second order word chains from: Odyssey.txt
 
Below is a random block of 200 words of pseudo text that was created using the second order Markov word-chain dictionary derived from the above text(s). 
 
The opening words were chosen as "the Templars "...
 
 the Templars dangerous situation than he proved that his tears he let fall the tears in her weeds. Notice because I'm not saying nothing, by the speed man and foredone with travail may in the Castle. [Hautboys and torches. Enter, and pass over, a Sewer and divers weighty causes call us And show like those that Odysseus shall not overcome my patience. And now, while I examine it, very closely. In a highly intelligible finger-post to the smoke, for the surprise if I wrote. Make the gruel thick and sharp reining up his glass again: which was plain but nice. She said all right, den. I doan' skasely ever cry." So Tom was satisfied. Then he took away the stem of a flying machine. Wonder does the ransom thou hast at home and beauty of the leaders, they commenced the proceedings it was supposed to respect the persons with whom they seemingly formed an object without having entered the hall, paused by the female choristers, the others before him: and yet will we let them send up to him winged words: 'Farewell, stranger, and fail not, and yet better known than he, and in no esteem, so noble a man can shoot

 

And here is an example of a third order output:

 

And here is the code that produced it:

Markov.py   
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Creates random, often grammatically correct, but ultimately meaningless text
using Markov word chains created from sample text files provided by the user.
 
A fun utility for creating random text using the word chains found in the user-
specified input files. It uses Markov prediction on a word level, choosing at
random the next word from those found to follow each chain of 1, 2, 3 or 4
words. It then drops the first word and adds the new one to create a new word
chain, and chooses randomly from the list of possible words in the input texts.
 
It does this by creating a dictionary of word chains, , and the words that can
follow them. The size of the sliding word window depends on the Markov order
the user has set. The highest Markov Order allowed has been limited to 4 words,
as higher tends merely to lift chunks of text from only one of the input files.
 
In programming terms, it performs a user-specified Nth order Markov sequential
word analysis upon the named files, creating a paragraph of nonsense text using
words in the same short sequences found in the input files, printing the result
to the standard output. The order of the analysis defines the length of the
word sequences used (default 2). This implementation leaves punctuation and
capitalization of proper nouns as found.
 
The following options are available:
    -w ParaWordLength
        Create a paragraph of pseudo text of WordLength words long.
        Default is 200 words.
 
    -o Order
        Set Markov order to Order. Default is 2 words. Orders of 2 and 3 give
        good results. Order = 1 creates text that is not quite grammatically
        correct but still readable. Orders of 4 tend to lift large chunks of
        text from each source in turn. Orders of 5 or more give a command line
        error since they simply lift a random block of text from one input
        file. If the flag is used but an order not found a command line
        error will be given.
 
    -s "starting phrase"
        Start the pseudo text with the user-provided starting phrase. The phrase
        must appear exactly as entered in one of the input files, and its length
        must equal the length chosen as the Markov order with the -o flag, or
        the default 2. If the flag is used but a phrase not found a command line
        error will be given.
 
    -t
        Time the running of the program
 
    -h
        Print this usage guide ignoring all other flags
 
Examples:
        python3 Markov.py -o3 -w400 'The Odyssey.txt' 'Ulysses.txt'
 
        ./Markov.py -w300 -o2 -s 'Mr Bloom' 'Ulysses.txt' > output_file.txt
 
The user should not have to delete the header and footer text from the input
files - their words rarely pop up in the resulting text. Web and email
addresses are automatically filtered out of headers and footers.
 
 
Possible future improvements:
- add Linux double dash -- verbose command line flags.
- continue the text until a full stop (period) is encountered.
-
 
Known bugs:
-
 
author: matta_idlecoder at protonmail dot com
"""
 
import timeit
import string
import random
import sys
import getopt
import os
 
timing = False
result_string = ''
 
 
def cleanup(word):
    """  Cleans a word of punctuation, numbers, and web & email addresses.
    """
    clean_text_str, new_text = '', []
    char_and_punct_list = list(word)
    CharsToKeep = string.ascii_letters + string.punctuation
 
    if ('@' or 'http' or 'www' or '\.com' or '\.edu' or '\.org' or '\.gov') in word:
        return ''
    for char in char_and_punct_list:
        # first pass - remove digits & unwanted punctuation:
        if (char in CharsToKeep):
            new_text.append(char)
    if len(new_text) == 0:
        return ''
    if len(new_text) > 0:
        # convert mutable list of chars back to immutable string:
        clean_text_str = ''.join(new_text)
    return clean_text_str
 
 
def get_Markov_chains(markov_order, input_file):
    """Creates a Markov dict from the input file words, of the order specified
 
    The Markov order defines the key length. The values are all single words.
    """
    current_line_words = []
    word_suffix_list = dict()
    phrase = []
    phrase_key = tuple()
 
    """The code has already checked that the file exists, so it doesn't needs
    a try,,,,except loop around it:
    """
    fin = open(input_file)
 
    for line in fin:
        # turn the line into a list of words:
        line = line.replace('--', ' ') # found in some English literature
        current_line_words = line.split()
        if len(current_line_words) == 0:
            continue
 
        # Create the Markov dictionary from the list:
        for next_word in current_line_words:
            next_word = cleanup(next_word)
            if len(next_word) == 0:
               continue
            if len(phrase) == markov_order:
                if phrase_key not in word_suffix_list:
                    word_suffix_list[phrase_key] = set()
                word_suffix_list[phrase_key].update([next_word])
                del phrase[0]
            phrase.append(next_word)
            phrase_key = tuple(phrase)
    fin.close()
    return word_suffix_list
 
 
def usage():
    usage_str = "markov, version 1.0\n\nusage: ./Markov.py [-ht] [-o order] "
    usage_str += "[-w paragraph wordlength] [-s start phrase] input_files\n"
    print (usage_str)
    return
 
 
def pick_item(some_sequence):
    """Pick a random item from the sequence provided.
 
    Python duck typing means it can any of list(), dict(), set() or even string
    """
    num_words = len(some_sequence)
    list_of_items = list(some_sequence)   # works on lists
    item_index = random.randint(0, num_words-1)
    random_item = list_of_items[item_index]
    return random_item
 
 
def augment_result_string(*args):
    """Assembles output result as a returned continuous string
    """
    global result_string
    for thing in args:
        result_string += str(thing)
        result_string += ' '
    return
 
 
def left_shift(markov_prefix, word):
    """Shift the markov_prefix left once, add word on the end, return new tuple.
    """
    return markov_prefix[1:] + (word,)
 
 
def Ordinal(integer):
    """Converts an integer from 1-4 into an ordinal string
    """
    if integer == 1:
        return 'first'
    elif integer == 2:
        return 'second'
    elif integer == 3:
        return 'third'
    elif integer == 4:
        return 'fourth'
    else:
        return 'some unknown'
 
 
def create_markov_text_from(pathnames, markov_order=2, word_len=200, 
                            starting_words=''):
    """ Create random text from files in pathnames list, using Markov
 
    Works at word level, not letters.
    """
    global result_string
    result_string = ''
    markov_dict = dict()
 
    order_as_text = Ordinal(markov_order)
    first_file = True
 
    goal = '\nThe following text(s) will now be analysed to create a {} order '
    goal += 'Markov word-chain dictionary:\n\n'
    augment_result_string(goal.format(order_as_text))
 
    for pathname in pathnames:
        path, filename = os.path.split(pathname)
        augment_result_string('* ', filename, '\n')
 
    augment_result_string('\n')
 
    if timing:
        zerotime = timeit.default_timer()
 
    for pathname in pathnames:
        path, filename = os.path.split(pathname)
        augment_result_string('Getting {} order word chains from: {}\n'.
                              format(order_as_text, filename))
 
        if first_file == True:
            if timing:
                now = timeit.default_timer()
                augment_result_string('\tElapsed time: ', 
                                      round(now - zerotime, 2), 'seconds.\n')
            markov_dict = get_Markov_chains(markov_order, pathname)
            first_file = False
 
        else:
            if timing:
                now = timeit.default_timer()
                augment_result_string('\tElapsed time: ', 
                                      round(now - zerotime, 2), 'seconds.\n')
            next_markov_dict = get_Markov_chains(markov_order, pathname)
 
            for key in next_markov_dict:
                if key in markov_dict:
                    markov_dict[key] = markov_dict[key] | next_markov_dict[key]
 
                else:
                    markov_dict[key] = next_markov_dict[key]
 
    output_intro = '\nBelow is a random block of {} words of pseudo text '
    output_intro += 'that was created using the {} order Markov word-chain '
    output_intro += 'dictionary derived from the above text(s). '
    if starting_words == '':
        output_intro += '\n\nThe opening words were chosen at random.\n\n'
    else:
        output_intro += '\n\nThe opening words were chosen as "{}"...\n\n'.format(starting_words)
 
    augment_result_string(output_intro.format(word_len, order_as_text))
    if starting_words == '':
        markov_key_words = pick_item(markov_dict)
    else:
        markov_key_words = tuple(starting_words.split())
        assert len(markov_key_words) == markov_order
        if markov_key_words not in markov_dict:
            cmd_line_error = '\nCheck your opening words - those you have chosen '
            cmd_line_error += 'cannot be found in any of the input texts.'
            if __name__ == '__main__':
                print(cmd_line_error)
                sys.exit(2)
            else:
                return '', cmd_line_error
 
    augment_result_string(*markov_key_words)  # Start the paragraph with the starting_words
    for i in range(word_len):
        next_word_set = markov_dict[markov_key_words]
        next_word = pick_item(next_word_set)
        augment_result_string(next_word)
        markov_key_words = left_shift(markov_key_words, next_word)
    augment_result_string('\n\n')
 
    return (result_string, 'OK')
 
 
def main(argv):
 
    global timing
    timing = False
    MarkovOrder = 2
    ParaLength = 200
    StartText = ''
    PathList = []
    output_paragraph = ''
 
    try:
        opts, args = getopt.getopt(argv, "o:w:s:th")
 
    except getopt.GetoptError as cmd_err:
        print (str(cmd_err))
        usage()
        sys.exit(2)  # (Unix convention: 0=no problem, 1=error, 2=cmdline)
 
    for opt, value in opts:
        if opt == '-o':
            try:
                MarkovOrder = int(value)
                if MarkovOrder > 4:
                    high_markov_error = "\nWhat's the point? All this order "
                    high_markov_error += "will do is lift a block of verbatim "
                    high_markov_error += "text from one of your input files!\n"
                    print (high_markov_error)
                    sys.exit(2)
            except ValueError:
                print ('\nCommand line error: missing integer with -o flag.\n')
                sys.exit(2)
 
        elif opt == '-s':
            StartText = value
            if len(StartText.split()) != MarkovOrder:
                Markov_error = '\nUser error on command line: the number of '
                Markov_error += 'starting words must equal the Markov order.\n'
                print (Markov_error)
                sys.exit(2)
 
        elif opt == '-w':
            try:
                ParaLength = int(value)
                if (ParaLength < 50) or (ParaLength > 1000):
                    print ("Try a paragraph length between 50 and 1,000 words")
                    sys.exit(2)
            except ValueError:
                print ('\nCommand line error: missing integer with -w flag.\n')
                sys.exit(2)
 
        elif opt == '-t':
            timing = True
            start = timeit.default_timer()
 
        elif opt == '-h':
            usage()
            print (__doc__, '\n')
            sys.exit(0)
 
        else:
            print ('\nopt = {}\n'.format(opt))
            assert False, "Programming error: main() has an unhandled option"
 
    if len(args) == 0:
        print ('\nCommand line error: requires at least one input file.\n')
        usage()
        sys.exit(2)
 
    # check the input files are there before proceeding:
    for arg in args:
        try:
            f = open(arg, 'r')
        except IOError:
            print ('\nFilename error: no such file: {}\n'.format(arg))
            sys.exit(2)
        else:
            f.close()
            PathList.append(arg)
 
    output_paragraph, error = create_markov_text_from(PathList,
                            markov_order=MarkovOrder, word_len=ParaLength,
                            starting_words=StartText)
    if error != 'OK':
        print('Error returned from create_markov_text_from(): {}'.format(error))
        sys.exit(1)
    else:
        print(output_paragraph)
 
    if timing:
        stop = timeit.default_timer()
        print ('\nThe creation of this Markov text took', 
               round(stop - start, 2), 'seconds.\n')
    return
 
 
 
if __name__ == "__main__":
    main(sys.argv[1:])

 

What Is Happening

A quick note on how this works, without sending you off to some incomprehensible web site on Markovian theory that ends up baffling you (and me). Put simply, Markovian prediction is a way of guessing what happens next, based on examples of what’s happened before. Markov chain algorithms are used by everything from search engines anticipating the next word you might type, to stock market speculators guessing where a price might go next. They are sometimes right but usually wrong. They should be used with other tools only to add weight to a particular prediction that something is about to happen. But they should never be relied upon. Some neuro-linguists have claimed that it’s one of the tools our brains use to parse language.

In textual analysis. a Markov word-chain is just a dictionary, with the short word chains that have been found assigned to its Keys, whose corresponding dictionary Values are simply lists of all the next possible single words found to follow each word-chain in the input texts given as examples to the program.

If we ran the program on a collection of European fairy stories, the {key:value} dictionary pairs for a second order Markov word chain might be:

Key = a string Value = a list
‘Once upon’ [‘a’]
‘upon a’ [‘time’, ‘star’]
‘a time’ [‘there’, ‘like’, ‘for’, ‘to’, ‘in’]
‘a star’ [‘is’, ‘like’, ‘called’, ‘with’, ‘in’, ‘circling’]

And so on. Note that the Key strings have two words, hence this is second order.

It works like this: let’s say that the only word found after ‘Once upon’ in any of the fairy stories is ‘a’. The ‘Once’ is then dropped and the ‘a’ is added on the end to create a new Key ‘upon a’. The dictionary is then searched again with this new Key, and ‘time’ and ‘star’ are found as the only two possible words that can follow it in the input texts. The word ‘star’ is selected at random from the two possibilities and the new Key becomes ‘a star’, and so on. At every stage, the next word is simply chosen at random from the list of possible next words found in the input texts.

If the user had asked for a third order Markov dictionary to be created from the fairy story collection, it might have looked like this:

Key Value
‘Once upon a’ [‘time’]
‘upon a time’ [‘there’]
‘a time there’ [‘I’, ‘lived’, ‘in’]

You can see that the longer the Keys get, the shorter the list of possible next words in the corresponding Value. This has the following consequences:

      • For Markov orders of 4 or more, the program tends just to lift chunks of text from one input file.
      • The more input texts you use to create the chains, the longer the lists of possible next words, and so the more permutations of output text are possible.
      • The shorter the order, the crazier the text gets, often at the expense of grammar.
      • If the order is one, the key is just replaced each time by one of its successor words.

Potential Command Line Pitfalls

For the command line to work and the execution not to abort, a potential user is presented with the following challenges:

      1. The order set by the –o flag must be between 1 and 4 (for reasons given above).
      2. The word length must be between 100 and 1000. (No reason, but you’ve got to draw the line somewhere.)
      3. The opening words must occur in one of the input files, in the same order, so that their successor words can be found in the Markov word chains. In other words, the opening words must be one of the word sequences found.
      4. The number of opening words chosen must correspond to the Markov order set by the –o flag.

The Solution

A Tkinter dashboard was written to launch the above program. As with the previous post, it was then turned into a working Python GUI template which, once understood, can be rapidly adapted as a user interface for other programmers to use with their own Python code.

Unlike the last post, this template has been written to handle multiple input files and both textual and numerical input data. As can be seen from the screenshots at the top of this page, it has been tested on Windows 10, Mac OS/X and Linux Mint.(1) In addition, it provides all of the required functionality of the command line, with the following features:

Selecting multiple input files for Python using Tkinter
Selecting multiple input files.

 

Using Tkinter to guide the user through your Python program
Input of textual input data, with guidance and an explanation of how to do it.

 

Using Tkinter to issue warnings when the user tries to run your Python program with conflicting or inconsistent settings
A warning when the user tries to run before choosing any input files (left) and cross-checking for consistency between the settings (in this example, giving a warning when the order is different to the number of opening words).

 

Using Tkinter to check types and values of user inputs to a Python program
Checking of types and values of inputs (ignoring unwanted data by type and rejecting unwanted data by value)

 

Using Tkinter to save the Python program's results to a filename chosen by the user
A way of saving the results to a filename chosen by the user.

How To Install It

The code to produce this result is as follows:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""This GUI program will present the user with a simple front end dashboard for
the Python command line program Markov.py to select the files it will use to 
build its Markov word chains. It will also ask the user to define the order of 
the Markov chains to use, and the size of the output paragraph to create, 
saving the results in a file name of the user's choice.
 
author: matta_idlecoder at protonmail dot com
"""
from tkinter import Tk, Frame, StringVar, IntVar
from tkinter import RAISED, END, E, W, CENTER, LEFT, RIGHT
from tkinter import Button, NORMAL, DISABLED
from tkinter import Toplevel, Entry, Label
import tkinter.constants, tkinter.filedialog
import tkinter.messagebox, tkinter.font
import os
import platform
 
from Markov import create_markov_text_from
 
 
def Cardinal(integer):
    """Converts an integer from 1-4 into a cardinal text string
    """
    if integer == 1:
        return 'one'
    elif integer == 2:
        return 'two'
    elif integer == 3:
        return 'three'
    elif integer == 4:
        return 'four'
    elif integer == 5:
        return 'five'
    elif integer == 6:
        return 'six'
    elif integer >= 7:
        return str(integer)
 
 
class PythonGUI_2(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.NormalFont = ('Helvetica', 14)
        self.FancyFont = ('Caladea', 15)
 
        self.std_button_opts = {'wraplength':"6c", 'relief':RAISED, 'bd':4,
                                'padx':10, 'pady':10}
 
        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.save_path_and_name = None
        self.path_list = []
 
        self.fileSelector = StringVar()
        self.FileCountButton = StringVar()
        self.fileSelector.set('Select files')
        self.FileCountButton.set('No files selected')
 
        self.FirstWordsButtonName = StringVar()
        self.FirstWordsDisp = StringVar()
        self.FirstWordsString = StringVar()
 
        self.OrderButtonName = StringVar()
        self.ParaLenButtonName = StringVar()
 
        self.MarkovOrder = IntVar()
        self.ParaLength = IntVar()
 
        self.MarkovOrder.set(2)
        self.ParaLength.set(200)
 
        self.OrderButtonName.set('Markov Order will be {:d}'.format(self.MarkovOrder.get()))
        self.ParaLenButtonName.set('Paragraph:{} words long'.format(self.ParaLength.get()))
 
        self.reset_first_words()
 
        self.YourAppDashboard()
        return
 
 
    def YourAppDashboard(self):
        """The function that creates your dashboard and all its widgets
        """
 
        std_pack_opts = {'padx':10, 'pady':10}
 
        BlankLine = ' ' * 80
 
        Label(self, text="\nMarkov Random Text Generator", font=('MS Gothic', 
                                                                 16)).pack()
        Label(self, text=BlankLine).pack()
 
        self.ListFilesButton = Button(self, textvariable=self.FileCountButton,
                                font=self.NormalFont, command=self.ListFiles,
                                state=DISABLED, **self.std_button_opts)
        self.ListFilesButton.pack(**std_pack_opts)
 
        Button(self, textvariable=self.fileSelector, command=self.GetFileList,
                               font=self.FancyFont,
                               **self.std_button_opts).pack(**std_pack_opts)
 
        Label(self, text=BlankLine).pack()
 
        Button(self, textvariable=self.FirstWordsButtonName,
                                command=self.GetFirstWords,
                                font=self.NormalFont,
                                **self.std_button_opts).pack(**std_pack_opts)
 
        self.OrderButton = Button(self, textvariable=self.OrderButtonName,
                                cursor='plus', font=self.NormalFont,
                                command=lambda:
                                self.UpdateNumberButton(Parent='self',
                                Variable='self.MarkovOrder',
                                ButtonName='self.OrderButtonName',
                                Font=self.NormalFont,
                                ButtonFormat='.set("Markov Order will be {:d}".format(NewValue))',
                                Title='Order',
                                Instruction='\nEnter the Markov order\nyou want to use:',
                                Advice='   Number should be between {:d} and {:d}.   ',
                                Currency='',
                                Minimum = 1, Maximum = 4),
                                **self.std_button_opts)
        self.OrderButton.pack(**std_pack_opts)
 
        self.ParaLenButton = Button(self, textvariable=self.ParaLenButtonName,
                                cursor='plus', font=self.NormalFont,
                                command=lambda:
                                self.UpdateNumberButton(Parent='self',
                                Variable='self.ParaLength',
                                ButtonName='self.ParaLenButtonName',
                                Font=self.NormalFont,
                                ButtonFormat='.set("Paragraph: {:d} words long".format(NewValue))',
                                Title='Length',
                                Instruction='\nEnter the length of paragraph\nyou want to create:',
                                Advice='   Number should be between {:d} and {:d}.   ',
                                Currency='',
                                Minimum = 100, Maximum = 1000),
                                **self.std_button_opts)
        self.ParaLenButton.pack(**std_pack_opts)
 
        Label(self, text=BlankLine).pack()
 
        Button(self, text='Create Markov Text >>>', command=self.run_markov,
                                font=self.FancyFont,
                                **self.std_button_opts).pack(**std_pack_opts)
 
        Label(self, text=BlankLine).pack()
 
        Button(self, text='Quit', command=self.quit, font=self.NormalFont,
                                **self.std_button_opts).pack(**std_pack_opts)
        return
 
 
 
    def onAdviceOKButton(self, parent):
        """Kills the parent window when OK is clicked
        """
        self.InfoWindow.grab_release()
        eval(parent + '.grab_set()')
        self.InfoWindow.destroy()
 
 
    def InfoBox(self, Parent='root', Title='', Font=('Helvetica', 12),
                Text='Missing text'):
        """General utility for getting user confirmation on a status
        """
        self.InfoWindow = Toplevel(self)
        eval(Parent + '.grab_release()')
        self.InfoWindow.grab_set()
        geom_string = "+{}+{}".format(w-25, h+120)
        self.InfoWindow.geometry(geom_string)
        self.InfoWindow.title(Title)
 
        Label(self.InfoWindow, text=Text, width=30, font=Font,
              wraplength=200).pack(padx=10)
 
        Button(self.InfoWindow, text='OK', justify=CENTER, font=Font,
               padx=15, pady=5, command=lambda:
               self.onAdviceOKButton(Parent)).pack(pady=10)
        return
 
 
    def ListFiles(self, parent='root', MaxShow=10):
        """List the files selected by the user
        """
        NameList, FileCount = [], 0
        NumFiles = len(self.path_list)
 
        if NumFiles > MaxShow:
            if NumFiles==(MaxShow+1):
                MaxShow -=1
            FilesLeft = NumFiles - MaxShow
 
        for path in self.path_list:
            Folder, FileName = os.path.split(path)
            NameList.append(FileName)
            FileCount += 1
            if FileCount == MaxShow:
                if NumFiles > MaxShow:
                    NameList.append("   Plus {} more...".format(Cardinal(FilesLeft)))
                break
 
        FileList = "\n"
        FileList += "\n\n".join(NameList)
        FileList += "\n"
 
        self.InfoBox(Parent=parent, Title="Files Chosen:", Text=FileList,
                     Font=self.NormalFont)
        return
 
 
    def reset_first_words(self):
        """Resets all variables related to the opening words in the paragraph
        """
        self.FirstWordsButtonName.set('No opening words are set: choose a group at random')
        self.FirstWordsDisp.set('=== COPY & PASTE YOUR OPENING WORDS HERE ===')
        self.FirstWordsString.set("")
        return
 
 
    def onStartButton1(self):
        """This is called when the user wants to save the pasted opening words
        """
        if (self.FirstWordsDisp.get()[:3] == '===')  or \
            (len(self.FirstWordsDisp.get()) == 0):
            self.reset_first_words()
        else:
            """Newline forces button text to 2 lines, which maintains the
            button shape when the text gets longer when the words are shown:
            """
            self.FirstWordsString.set(self.FirstWordsDisp.get())
            self.FirstWordsButtonName.set('Opening words will be: "{}"...'.
                                          format(self.FirstWordsDisp.get()))
 
        self.FirstWordsWin.destroy()
        return
 
 
    def onStartButton2(self):
        """Called when the user decides not to save the pasted opening words
        """
        self.reset_first_words()
        self.FirstWordsWin.destroy()
        return
 
 
    def GetFirstWords(self):
        """Opens Entry dialog box for user to paste their opening words into
        """
        self.FirstWordsWin = Toplevel(self)
        root.grab_release()
        self.FirstWordsWin.grab_set()
 
        geom_string = "+{}+{}".format(w-120, h+90)
        self.FirstWordsWin.geometry(geom_string)
 
        self.FirstWordsWin.title('Pick your opening words')
 
        Instruction = "\nType or copy & paste some opening words from one of your "
        Instruction += "chosen text files into the space below. The random "
        Instruction += "Markov text will begin with these words. "
        Instruction += "Don't worry about putting them in quotation marks:"
 
        Label(self.FirstWordsWin, text=Instruction, font=self.NormalFont,
              wraplength="11c").pack(pady=5)
 
        FirstWordsEntry = Entry(self.FirstWordsWin, font=self.NormalFont,
                             textvariable=self.FirstWordsDisp,
                             width=80, justify='center')
        FirstWordsEntry.pack(pady=5)
        FirstWordsEntry.focus_set()
        FirstWordsEntry.select_range(0, END)
 
        button_1_title = "START my Markov paragraph with the above words"
        Button(self.FirstWordsWin, text=button_1_title, font=self.NormalFont,
               padx=5, pady=5, command=self.onStartButton1).pack(pady=5)
 
        button_2_title = "Don't worry about the opening words. Start my Markov "
        button_2_title += "nonsense text with a randomly selected sequence of "
        button_2_title += "words from one of my text files."
 
        Button(self.FirstWordsWin, text=button_2_title, font=self.NormalFont,
               command=self.onStartButton2, **self.std_button_opts).pack(pady=5)
        return
 
 
    def ValidateInt(self, new_text):
        '''Checks every char UpdateNumberButton() gets is part of a valid int
        '''
        if not new_text:
            return True
        try:
            entered_number = int(new_text)
            self.UpdateButton.config(state=NORMAL)
            return True
        except ValueError:
            return False
 
 
    def onUpdateNumberButton(self, parent, minimum, maximum, variable,
                             button_name, button_format):
        """Checks the value entered is correct. Updates variable and button name
        """
        NewValue = int(self.NewNumEntry.get())
 
        if ((maximum is not False) and (minimum <= NewValue <= maximum)) or \
            ((maximum is False) and (minimum <= NewValue)):
 
            eval(variable + '.set({})'.format(NewValue))
            eval(button_name + button_format)
 
            self.NewNumberEntryWin.grab_release()
            eval(parent + '.grab_set()')
            self.NewNumberEntryWin.destroy()
            return
 
        else:
            self.NewNumEntry.bell()
            self.UpdateButton.config(state=DISABLED)
            self.NewNumEntry.delete(0, END)
            return
 
 
    def UpdateNumberButton(self, Parent='root', Variable='self.DollarAmount',
                    ButtonName='self.NoButtonName', Font=('Helvetica', 11),
                    ButtonFormat='.set("${:,.0f}".format(NewValue))',
                    Title='Unknown Update',
                    Instruction='Check your code: window needs an instruction',
                    Minimum=0, Maximum=1, Width=9,
                    Advice='   Figure should be between ${:0,.0f} and ${:0,.0f}.   ',
                    Currency='          $', Suffix=''):
        """Opens a child Entry window to ask for the new number
        """
        self.NewNumberEntryWin = Toplevel(self)
        eval(Parent + '.grab_release()')
        self.NewNumberEntryWin.grab_set()
 
        geom_string = "+{}+{}".format(w-25, h+300)
        self.NewNumberEntryWin.geometry(geom_string)
 
        self.NewNumberEntryWin.title(Title)
 
        r=0; c=0
        self.NewCashInstruction = Label(self.NewNumberEntryWin, text=Instruction, font=Font)
        self.NewCashInstruction.grid(row=r, column=c, columnspan=3, padx=10)
 
        r+=1; c=0
        Label(self.NewNumberEntryWin, text=Currency, justify=RIGHT, font=Font).grid(
                                row=r, column=c, sticky=E)
 
        c+=1
        vcmd = self.NewNumberEntryWin.register(self.ValidateInt)
        self.NewNumEntry = Entry(self.NewNumberEntryWin, validate="key",
                                justify=CENTER, validatecommand=(vcmd, '%P'),
                                width=Width, font=Font)
        self.NewNumEntry.grid(row=r, column=c, pady=5)
        self.NewNumEntry.focus_set()
 
        c+=1
        Label(self.NewNumberEntryWin, text=Suffix, justify=LEFT, font=Font).grid(row=r,
                                column=c, sticky=W)
 
        c+=1
        self.UpdateButton = Button(self.NewNumberEntryWin, text='Update', font=Font,
                                justify=LEFT, state=DISABLED, width=6,
                                command=lambda:
                                    self.onUpdateNumberButton(Parent, Minimum,
                                    Maximum, Variable, ButtonName, ButtonFormat))
        self.UpdateButton.grid(row=r, column=c, padx=5, pady=5)
 
        r+=1; c=0
        if not Maximum:
            self.NewNumAdvice = Label(self.NewNumberEntryWin, text=Advice.format(
                                Minimum), font=Font)
        else:
            self.NewNumAdvice = Label(self.NewNumberEntryWin, text=Advice.format(
                                Minimum, Maximum), font=Font)
        self.NewNumAdvice.grid(row=r, column=c, pady=5, columnspan=4)
        return
 
 
    def GetFileList(self):
        """Returns a list of path names for your own code to open elsewhere
        """
        if self.path_list != []:
            # a set of files have been chosen before. Go to the same place:
            self.file_opt['initialdir'], an_old_text_name = os.path.split(self.path_list[0])
        else:
            old_path_list = self.path_list
            self.file_opt['initialdir'] = '.'
        old_path_list = self.path_list
 
        Key = 'CTRL'if platform.mac_ver()[0] == '' else 'Command'
 
        self.file_opt['title'] = 'Use SHIFT-click and {}-click to pick multiple text files:'.format(Key)
 
        self.file_opt['initialfile'] = '*.txt'
 
        self.path_list = list(tkinter.filedialog.askopenfilenames(**self.file_opt))
 
        if self.path_list != []:
            path_list_copy = self.path_list.copy()
            for index, path in enumerate(path_list_copy):
                if "*.txt" in path:
                    del self.path_list[index]
                    break
            num_files_chosen = len(self.path_list)
            verb = 'Add' if num_files_chosen == 1 else 'Change'
            plural = 's' if num_files_chosen > 1 else ''
            self.fileSelector.set('{} files'.format(verb))
            self.reset_first_words()
            number = Cardinal(num_files_chosen)
            self.FileCountButton.set('{} file{} selected'.format(number.capitalize(), plural))
        else:
            self.path_list = old_path_list
 
        if self.path_list != []:
            self.ListFilesButton.config(state=NORMAL)
        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
        self.file_opt['title'] = "Save results to:"
        self.file_opt['initialfile'] = 'Markov_Result.txt'
 
        self.save_path_and_name = tkinter.filedialog.asksaveasfilename(**self.file_opt)
        if self.save_path_and_name:
            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 run_markov(self):
        """A simple wrapper to pass your dashboard-set variables to your logic
        """
        result = ''
        FirstWords = self.FirstWordsString.get()
 
        if self.path_list == []:
            tkinter.messagebox.showinfo(title='No text files chosen   ',
                message="Please choose some texts to\nturn into meaningless gibberish.",
                icon='error')
 
        elif FirstWords != '' and (len(FirstWords.split()) != self.MarkovOrder.get()):
            tkinter.messagebox.showinfo(title='Markov Error   ',
                message="The number of opening words\nmust equal the Markov order.",
                icon='error')
 
        else:
            result, error  = create_markov_text_from(self.path_list,
                                    markov_order=self.MarkovOrder.get(),
                                    word_len=self.ParaLength.get(),
                                    starting_words=self.FirstWordsString.get())
            if error != 'OK':
                self.InfoBox(Parent='root', Title="Error", Text=error, Font=self.NormalFont)
                return
            else:
                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
    geom_string = "+{}+{}".format(w, h)
 
    root.geometry(geom_string)
    root.title('Dashboard')
    root.lift()
    PythonGUI_2(root).pack()
 
    root.mainloop()

 

As you now know, this needs to be copied into the same folder as the logic it will call, which in this case is Markov.py. Before running Markov_GUI.py, the folder should therefore have the following contents:

Markov.py
Markov_GUI.py
Ivanhoe.txt
Ulysses.txt
The Odyssey.txt

You’ll also need to copy into this folder whatever input files you want to use (I’ve shown Ivanhoe, Ulysses and The Odyssey), but they can be in their own folder. As long as you can find their  folder somewhere on your hard drive, you can select them with your GUI.

As before, to launch the dashboard, you just need to start a terminal window, change the currrent directory to the above folder, make the Markov_GUI.py code executable, and type:

$ python3 ./Markov_GUI.py

(Or whatever works on your system – see this post for some alternatives)

Once you’ve got it working, and you’ve managed to create your own samples of nonsense text, you’re ready to add the features it uses to the front-end dashboard you’re creating for your own program.

Second Python GUI Template

Take a moment to scan through the code listing given below. It’s functionally the same as the code above, but the listing now contains verbose comments on what each part does, and how to adapt it to configure and launch your own code:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""This GUI program is designed to be used as a simple front end for the Python
command line programs that require both numerical and textual input data, and
multiple input files.
 
The Tkinter widgets that it uses are for managing multiple input text files,
but may easily be adapted to other file types. The techniques it employs are
explained, both for file management and for input checking, so that they may be
reused and re-purposed for users' command line programs.
 
When run, it will present the user with a simple front end dashboard for the
Python command line program Markov.py to select the files it will use to build
its Markov word chains. It will also ask the user to define the order of the
Markov chains to use, and the size of the output paragraph to create, saving
the results in a file name of the user's choice.
 
author: matta_idlecoder at protonmail dot com
"""
 
"""All imported TK functions and variables have been named. The first four
lines could all have been replaced by:
 
    from tkinter import *
 
This way, you know where these functions and variables came from, and you
don't have any unknown functions or variables floating around. It also provides
an explicit inventory of what is being imported and used, 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 RAISED, END, E, W, EW, NS, CENTER, LEFT, RIGHT
from tkinter import Button, NORMAL, DISABLED
from tkinter import Toplevel, Entry, Label
import tkinter.constants, tkinter.filedialog
import tkinter.messagebox, tkinter.font
import os 
import platform
 
debugging = False
 
"""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 Markov.py import create_markov_text_from()
"""
from Markov import create_markov_text_from
 
 
def Cardinal(integer):
    """Converts an integer from 1-4 into a cardinal text string
    """
    if integer == 1:
        return 'one'
    elif integer == 2:
        return 'two'
    elif integer == 3:
        return 'three'
    elif integer == 4:
        return 'four'
    elif integer == 5:
        return 'five'
    elif integer == 6:
        return 'six'
    elif integer >= 7:
        return str(integer)
 
 
class PythonGUI_2(Frame):
    """The object space for creating your dashboard and launching your app
 
    Within this class, every function 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
 
        self.NormalFont = ('Helvetica', 14)
        self.FancyFont = ('Caladea', 15)
 
        """To print a list of all the available fonts you can use on your
        own platform, temporarily un-comment the following 4 lines and it will
        print the available font list to your terminal window when you run:
 
        font_list = list(tkinter.font.families())
        font_list.sort()
        print("\nNumber of available fonts = {}, and here they are:".format(len(font_list)))
        print("\nfont_list = {}\n\n".format(font_list))
        """
 
        # define the default button appearance: wrap any text to 6cm wide block:
        self.std_button_opts = {'wraplength':"6c", 'relief':RAISED, 'bd':4,
                                'padx':10, 'pady':10}
 
 
        """ 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.save_path_and_name = None
        self.path_list = []
 
 
        """ Define and initialise here any info status variables that will
        be set and displayed by widgets, or that 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() if they are
        to be set by widgets. Note that there is no Boolean() type. Is this is
        required, use IntVar() and set it to either 0 or 1. Python will
        interpret these values as True or False:
        """
        self.fileSelector = StringVar()
        self.FileCountButton = StringVar()
        self.fileSelector.set('Select files')
        self.FileCountButton.set('No files selected')
 
        self.FirstWordsButtonName = StringVar()
        self.FirstWordsDisp = StringVar()
        self.FirstWordsString = StringVar()
 
        self.OrderButtonName = StringVar()
        self.ParaLenButtonName = StringVar()
 
        self.MarkovOrder = IntVar()
        self.ParaLength = IntVar()
 
        self.MarkovOrder.set(2)
        self.ParaLength.set(200)
 
        self.OrderButtonName.set('Markov Order will be {:d}'.format(self.MarkovOrder.get()))
        self.ParaLenButtonName.set('Paragraph:{} words long'.format(self.ParaLength.get()))
 
        self.reset_first_words()
 
        # Call your dashboard:
        self.YourAppDashboard()
        return
 
 
    def YourAppDashboard(self):
        """The function that creates your dashboard and all its widgets
 
        Widgets only need the 'self' prefix if other functions within the class
        need to configure them. Otherwise, they can just be local.
        """
 
        # default options for widgets on this dashboard:
        std_pack_opts = {'padx':10, 'pady':10}
 
        BlankLine = ' ' * 80  # Simple way to space things out
 
        Label(self, text="\nMarkov Random Text Generator", font=('MS Gothic', 16)).pack()
        Label(self, text=BlankLine).pack()
 
        """This button is only made active when there are actually files to
        see. Its change of state happens in self.GetFileList, when files are
        chosen. Once self.path_list is a non-empty list, it becomes active,
        and the user can click on it. When it is clicked, OnListFile will
        simply list the filenames found in self.path_list:
        """
        self.ListFilesButton = Button(self, textvariable=self.FileCountButton,
                                font=self.NormalFont, command=self.ListFiles,
                                state=DISABLED, **self.std_button_opts)
        self.ListFilesButton.pack(**std_pack_opts)
 
        """This calls a tkinter widget that fetches multiple filenames:
        """
        Button(self, textvariable=self.fileSelector, command=self.GetFileList,
                               font=self.FancyFont,
                               **self.std_button_opts).pack(**std_pack_opts)
 
        Label(self, text=BlankLine).pack()
 
        """The same widget and underlying function that was used to set the
        start and stop triggers in the TextualAnalysis GUI:
        """
        Button(self, textvariable=self.FirstWordsButtonName,
                                command=self.GetFirstWords,
                                font=self.NormalFont,
                                **self.std_button_opts).pack(**std_pack_opts)
 
        """ These two widgets and their inderlying functions are for inputting
        and value checking numerical data. Here I'm passing input checking
        instructions to the function self.UpdateNumberButton() called by these
        widgets. These instructions are used in the function to check and set
        the variables, button names and tailor advice in the sub-windows.
 
        Note 1: normally, tkinter will not allow variables to be passed to the
        function called by the activation of the widget, which tk calls the
        handler. To get around this, you call a lambda function and pass the
        variables in the lambda call.
 
        Note 2: see the comments in self.onUpdateNumberButton() for what is
        happening with the formatting instructions being passed here.
 
        Note 3: There is no reason for using kwargs in the function call other
        than to make the code more readable. The normal reasons for using
        them - setting defaults, using optional parameters in function
        calls - may apply, but they are not the primary reason for using them:
        """
        self.OrderButton = Button(self, textvariable=self.OrderButtonName,
                                cursor='plus', font=self.NormalFont,
                                command=lambda:
                                self.UpdateNumberButton(Parent='self',
                                Variable='self.MarkovOrder',
                                ButtonName='self.OrderButtonName',
                                Font=self.NormalFont,
                                ButtonFormat='.set("Markov Order will be {:d}".format(NewValue))',
                                Title='Order',
                                Instruction='\nEnter the Markov order\nyou want to use:',
                                Advice='   Number should be between {:d} and {:d}.   ',
                                Currency='',
                                Minimum = 1, Maximum = 4),
                                **self.std_button_opts)
        self.OrderButton.pack(**std_pack_opts)
 
        self.ParaLenButton = Button(self, textvariable=self.ParaLenButtonName,
                                cursor='plus', font=self.NormalFont,
                                command=lambda:
                                self.UpdateNumberButton(Parent='self',
                                Variable='self.ParaLength',
                                ButtonName='self.ParaLenButtonName',
                                Font=self.NormalFont,
                                ButtonFormat='.set("Paragraph: {:d} words long".format(NewValue))',
                                Title='Length',
                                Instruction='\nEnter the length of paragraph\nyou want to create:',
                                Advice='   Number should be between {:d} and {:d}.   ',
                                Currency='',
                                Minimum = 100, Maximum = 1000),
                                **self.std_button_opts)
        self.ParaLenButton.pack(**std_pack_opts)
 
        Label(self, text=BlankLine).pack()
 
        # This button calls a handler function that calls your underlying logic
        Button(self, text='Create Markov Text >>>', command=self.run_my_code,
                                font=self.FancyFont,
                                **self.std_button_opts).pack(**std_pack_opts)
 
        Label(self, text=BlankLine).pack()
 
        Button(self, text='Quit', command=self.quit, font=self.NormalFont,
                                **self.std_button_opts).pack(**std_pack_opts)
        return
 
 
 
    def onAdviceOKButton(self, parent):
        """Kills the parent window when OK is clicked
        """
        self.InfoWindow.grab_release()
        eval(parent + '.grab_set()')
        self.InfoWindow.destroy()
 
 
    def InfoBox(self, Parent='root', Title='', Font=('Helvetica', 12),
                Text='Missing text'):
        """General utility for getting user confirmation on a status
        """
        self.InfoWindow = Toplevel(self)
        eval(Parent + '.grab_release()')
        self.InfoWindow.grab_set()
        geom_string = "+{}+{}".format(w-25, h+120)
        self.InfoWindow.geometry(geom_string)
        self.InfoWindow.title(Title)
 
        Label(self.InfoWindow, text=Text, width=30, font=Font,
              wraplength=200).pack(padx=10)
 
        Button(self.InfoWindow, text='OK', justify=CENTER, font=Font,
               padx=15, pady=5, command=lambda:
               self.onAdviceOKButton(Parent)).pack(pady=10)
        return
 
 
    def ListFiles(self, parent='root', MaxShow=10):
        """List the files selected by the user
        """
        NameList, FileCount = [], 0
        NumFiles = len(self.path_list)
 
        if NumFiles > MaxShow:
            if NumFiles==(MaxShow+1):
                MaxShow -=1   # avoids using a line to say  'Plus one more'
            FilesLeft = NumFiles - MaxShow  # now will always be at least 2 more
 
        for path in self.path_list:
            Folder, FileName = os.path.split(path)
            NameList.append(FileName)
            FileCount += 1
            if FileCount == MaxShow:
                if NumFiles > MaxShow:
                    NameList.append("   Plus {} more...".format(Cardinal(FilesLeft)))
                break
 
        FileList = "\n"
        FileList += "\n\n".join(NameList)
        FileList += "\n"
 
        self.InfoBox(Parent=parent, Title="Files Chosen:", Text=FileList,
                     Font=self.NormalFont)
        return
 
 
    def reset_first_words(self):
        """Resets all variables related to the opening words in the paragraph
        """
        self.FirstWordsButtonName.set('No opening words are set: choose a group at random')
        self.FirstWordsDisp.set('=== COPY & PASTE YOUR OPENING WORDS HERE ===')
        self.FirstWordsString.set("")
        return
 
 
    def onStartButton1(self):
        """This is called when the user wants to save the pasted opening words
        """
        if (self.FirstWordsDisp.get()[:3] == '===')  or \
            (len(self.FirstWordsDisp.get()) == 0):
            self.reset_first_words()
        else:
            """Newline forces button text to 2 lines, which maintains the
            button shape when the text gets longer when the words are shown:
            """
            self.FirstWordsString.set(self.FirstWordsDisp.get())
            self.FirstWordsButtonName.set('Opening words will be: "{}"...'.
                                          format(self.FirstWordsDisp.get()))
 
        self.FirstWordsWin.destroy()
        return
 
 
    def onStartButton2(self):
        """Called when the user decides not to save the pasted opening words
        """
        self.reset_first_words()
        self.FirstWordsWin.destroy()
        return
 
 
    def GetFirstWords(self):
        """Opens Entry dialog box for user to paste their opening words into
        """
        self.FirstWordsWin = Toplevel(self)
        root.grab_release()
        self.FirstWordsWin.grab_set()
 
        # This should line up the sub-windows with the buttons that called them:
        geom_string = "+{}+{}".format(w-120, h+90)
        self.FirstWordsWin.geometry(geom_string)
 
        self.FirstWordsWin.title('Pick your opening words')
 
        Instruction = "\nType or copy & paste some opening words from one of your "
        Instruction += "chosen text files into the space below. The random "
        Instruction += "Markov text will begin with these words. "
        Instruction += "Don't worry about putting them in quotation marks:"
 
        # Wrap the above text into an 11cm wide paragraph:
        Label(self.FirstWordsWin, text=Instruction, font=self.NormalFont,
              wraplength="11c").pack(pady=5)
 
        FirstWordsEntry = Entry(self.FirstWordsWin, font=self.NormalFont,
                             textvariable=self.FirstWordsDisp,
                             width=80, justify='center')
        FirstWordsEntry.pack(pady=5)
        FirstWordsEntry.focus_set()  # make the Entry box the active widget
        FirstWordsEntry.select_range(0, END) # highlight all the text
 
        button_1_title = "START my Markov paragraph with the above words"
        Button(self.FirstWordsWin, text=button_1_title, font=self.NormalFont,
               padx=5, pady=5, command=self.onStartButton1).pack(pady=5)
 
        button_2_title = "Don't worry about the opening words. Start my Markov "
        button_2_title += "nonsense text with a randomly selected sequence of "
        button_2_title += "words from one of my text files."
 
        Button(self.FirstWordsWin, text=button_2_title, font=self.NormalFont,
               command=self.onStartButton2, **self.std_button_opts).pack(pady=5)
        return
 
 
    def ValidateInt(self, new_text):
        '''Checks every char UpdateNumberButton() gets is part of a valid int
 
        Ignores entered chars that would make the total number not an int. This
        is a general purpose input type-enforcing function that ignores unwanted
        input. To force inputs of type float or string, simply create new
        functions called ValidateFloat() or ValidateString() from this code and
        use float(new_text) and str(new_text) as the first line of the try
        statement block below:
        '''
        if not new_text: # the field is being cleared
            return True
 
        try:
            """You don't care about entered_number. The point is to see if
            there is an error when you try to convert the input to the kind
            of data you want:
            """
            entered_number = int(new_text)
            self.UpdateButton.config(state=NORMAL)
            return True
 
        except ValueError:
            return False
 
 
    def onUpdateNumberButton(self, parent, minimum, maximum, variable,
                             button_name, button_format):
        """Checks the value entered is correct. Updates variable and button name
 
        This function checks that the value entered is within the correct
        range, between minimum and maximum. If not, it ignores the input and
        gives a warning beep. The parent window's Update button remains
        disabled until the input is valid. Once it is, it first updates
        variable to NewNumEntry, then updates button_name using button_format,
        by evaluating the Python meaning of strings passed down to it, e.g.
 
            buttonname = 'self.FundButtonName'
            and
            ButtonFormat = '.set("${:,}".format(NewValue))'
 
        which, when unpacked and executed consecutively by eval(), create a
        Python command which is then executed. This is a nice way to pass
        down a Tk StringVar variable and to modify both the variable and any
        widget that shows its value, once the 'Update' button has been
        pressed in the child window.
 
        Note 1: this is the right place to update the variable(s). To put the
        set() command in the parent function would only capture the initial
        value that the variable was set to when the parent window was first
        called into existence and drawn.
 
        Note 2: don't put any newlines into the button_format string. The
        newlines confuse the eval() function.
        """
        NewValue = int(self.NewNumEntry.get())
 
        if ((maximum is not False) and (minimum <= NewValue <= maximum)) or \
            ((maximum is False) and (minimum <= NewValue)):
 
            eval(variable + '.set({})'.format(NewValue))
            eval(button_name + button_format)
 
            self.NewNumberEntryWin.grab_release()
            eval(parent + '.grab_set()')
            self.NewNumberEntryWin.destroy()
            return
 
        else:
            self.NewNumEntry.bell()
            self.UpdateButton.config(state=DISABLED)
            self.NewNumEntry.delete(0, END)
            return
 
 
    def UpdateNumberButton(self, Parent='root', Variable='self.DollarAmount',
                    ButtonName='self.NoButtonName', Font=('Helvetica', 11),
                    ButtonFormat='.set("${:,.0f}".format(NewValue))',
                    Title='Unknown Update',
                    Instruction='Check your code: window needs an instruction',
                    Minimum=0, Maximum=1, Width=9,
                    Advice='   Figure should be between ${:0,.0f} and ${:0,.0f}.   ',
                    Currency='          $', Suffix=''):
        """Opens a child Entry window to ask for the new number
 
        This function uses the grid() geometry, which is OK as it is the only
        geometry used in the new window self.NumberInputWindow. By using the
        row=r, column=c system, the position of the widgets can easily be
        interchanged if you are designing a larget dashboard. This useful
        little window can be used to enter and check integers and floats,
        allowing the assignment of currency symbols and units and after the
        entry window.
 
        The Currency prefix and the Suffix can be set at the calling function
        to define what the number means. The function defaults to a currency
        update. The suffix can be set to units, or percentage.
 
        There's no reason for using kwargs in the function call other than to
        make the code infinitely more readable. The normal reasons for using
        them - setting defaults, using optional parameters in function
        calls - do not apply here. Had ButtonName and ButtonFormat simply been
        passed as strings in the function call, six months from now even I
        would have trouble working out why, and what those strings did.
        Alternatively, if positional variable names had been assigned and used,
        the variables would still have had to be assigned in 5-6 lines of code
        before the lambda was used, looking much like the list above.
        And making the parent code very cluttered.
        """
        self.NewNumberEntryWin = Toplevel(self)
        eval(Parent + '.grab_release()')
        self.NewNumberEntryWin.grab_set()
 
        geom_string = "+{}+{}".format(w-25, h+300)
        self.NewNumberEntryWin.geometry(geom_string)
 
        self.NewNumberEntryWin.title(Title)
 
        r=0; c=0
        self.NewCashInstruction = Label(self.NewNumberEntryWin, text=Instruction, font=Font)
        self.NewCashInstruction.grid(row=r, column=c, columnspan=3, padx=10)
 
        r+=1; c=0
        Label(self.NewNumberEntryWin, text=Currency, justify=RIGHT, font=Font).grid(
                                row=r, column=c, sticky=E)
 
        c+=1
        vcmd = self.NewNumberEntryWin.register(self.ValidateInt)
        self.NewNumEntry = Entry(self.NewNumberEntryWin, validate="key",
                                justify=CENTER, validatecommand=(vcmd, '%P'),
                                width=Width, font=Font)
        self.NewNumEntry.grid(row=r, column=c, pady=5)
        self.NewNumEntry.focus_set()
 
        c+=1
        Label(self.NewNumberEntryWin, text=Suffix, justify=LEFT, font=Font).grid(row=r,
                                column=c, sticky=W)
 
        c+=1
        self.UpdateButton = Button(self.NewNumberEntryWin, text='Update', font=Font,
                                justify=LEFT, state=DISABLED, width=6,
                                command=lambda:
                                    self.onUpdateNumberButton(Parent, Minimum,
                                    Maximum, Variable, ButtonName, ButtonFormat))
        self.UpdateButton.grid(row=r, column=c, padx=5, pady=5)
 
        r+=1; c=0
        if not Maximum:
            self.NewNumAdvice = Label(self.NewNumberEntryWin, text=Advice.format(
                                Minimum), font=Font)
        else:
            self.NewNumAdvice = Label(self.NewNumberEntryWin, text=Advice.format(
                                Minimum, Maximum), font=Font)
        self.NewNumAdvice.grid(row=r, column=c, pady=5, columnspan=4)
        return
 
 
    def GetFileList(self):
        """Returns a list of path names for your own code to open elsewhere
 
        This is different to GetNameOfTextToAnalyse() used in the previous
        post. Instead, this function returns multiple filenames by calling
        the tk file handler askopenfilenames(), rather than the singular
        askopenfilename(). It assigns a list of full file paths to
        self.path_list[]
        """
        if self.path_list != []:
            # a set of files have been chosen before. Go to the same place:
            self.file_opt['initialdir'], an_old_text_name = os.path.split(self.path_list[0])
        else:
            old_path_list = self.path_list
            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'
            """
        old_path_list = self.path_list
 
        # Detects whether user's computer is Mac or not:
        Key = 'CTRL'if platform.mac_ver()[0] == '' else 'Command'
 
        # Define what the user will see in the open file window.
        self.file_opt['title'] = 'Use SHIFT-click and {}-click to pick multiple text files:'.format(Key)
 
        self.file_opt['initialfile'] = '*.txt'
 
        # returns a tuple of file paths, or the empty tuple ():
        self.path_list = list(tkinter.filedialog.askopenfilenames(**self.file_opt))
 
        if self.path_list != []:
            # User picked some files, and didn't hit cancel:
 
            path_list_copy = self.path_list.copy()
            for index, path in enumerate(path_list_copy):
                if "*.txt" in path:
                    del self.path_list[index]  # fixes a Tk implementation bug in Linu
                    break
 
            num_files_chosen = len(self.path_list)
            verb = 'Add' if num_files_chosen == 1 else 'Change'
            plural = 's' if num_files_chosen > 1 else ''
            self.fileSelector.set('{} files'.format(verb))
            self.reset_first_words()
            number = Cardinal(num_files_chosen)
            self.FileCountButton.set('{} file{} selected'.format(number.capitalize(), plural))
 
        else:
            # Reset pathlist to last valid list:
            self.path_list = old_path_list
            # button names and opening words should be unchanged
 
        if self.path_list != []:
            # activate the next button down to allow the user to see the files selected:
            self.ListFilesButton.config(state=NORMAL)
        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
 
        # Define what the user will see in the save file window:
        self.file_opt['title'] = "Save results to:"
        self.file_opt['initialfile'] = 'Markov_Result.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 run_my_code(self):
        """A simple wrapper to pass your dashboard-set variables to your logic
        """
        result = ''
        FirstWords = self.FirstWordsString.get()
 
        """Place any error checking here for the variables set by your dashboard,
        before you send them to your logic. You have to process the errors
        in windows, and allow the program to continue execution:
        """
        if self.path_list == []:
            tkinter.messagebox.showinfo(title='No text files chosen   ',
                message="Please choose some texts to\nturn into meaningless gibberish.", icon='error')
 
        elif FirstWords != '' and (len(FirstWords.split()) != self.MarkovOrder.get()):
            tkinter.messagebox.showinfo(title='Markov Error   ',
                message="The number of opening words\nmust equal the Markov order.",
                icon='error')
 
        else:
            """You don't explicitly have to set and pass EVERY one of your
            variables to your logic. If some are not named, they will assume
            the defaults you have given them in your function's kwarg
            declarations. Others can be hard preset here by name, rather than
            cluttering up the GUI with options that will probably never change,
            or that the user doesn't need to see:
            """
            result, error  = create_markov_text_from(self.path_list,
                                    markov_order=self.MarkovOrder.get(),
                                    word_len=self.ParaLength.get(),
                                    starting_words=self.FirstWordsString.get())
            if error != 'OK':
                self.InfoBox(Parent='root', Title="Error", Text=error, Font=self.NormalFont)
                return
            else:
                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_2(root).pack()
 
    root.mainloop()

 

The Tkinter geometries I’ve used are both pack() and grid(), but never in the same window.   The pack() geometry is used for the main dashboard, and stacks the widgets in one column from top to bottom. I’ve introduced grid() to create small information and data entry boxes with widgets in rows and columns. It allows me to use four columns in the central row, to prefix the entry box with a currency sign or follow it with units. If you’ve taken the time to understand how pack() works, you’ll find it easy to work out how this box works, and you’ll find grid() on a whole pretty easy to understand too. (1)

As can be seen from the code, the biggest difference to the previous post is that the GUI can select multiple input files for your back-end logic to open. The function self.GetFileList() is where this happens. The Tk function that does the work differs from the last one used by one letter: tkinter.filedialog.askopenfilenames(). Instead of returning a single valid file path as a string, it returns a list of complete, valid file path strings for your code to open, read, do stuff with, and close. (2)

The other major difference is that it asks the user for numerical inputs, checking the inputs are correct for both type and value. The self.UpdateNumberButton() function, with its sub-functions self.onUpdateNumberButton() and self.ValidateInt() can easily be adapted to check for floats or strings. The comments in the code explain how to do this.

The logic calling function here is self.run_my_code(). This is the function that serves as the wrapper to your back-end code. But before it calls your actual code, it must first do all of its option consistency checks, and provide the appropriate error messages to guide the user through fixing any incorrect dashboard choices they may have made. Here, it  checks that at least one file is selected, and that the number of opening words equals the Markov order selected. The dashboard won’t call the back end logic until the user has fixed these, and instructs the user how to do so.

I’ve also defined different fonts for different kinds of widgets. This is useful to give your dashboard some variety. If you feel like using different fonts for your buttons, see the __init__() function in the class PythonGUI_2 for how to check which fonts are available on your system. If you’re planning on running your dashboard across different platforms (Windows, Linux, Mac) it would be best to avoid anything too fancy.

In Summary

The features of this second Python dashboard are designed as a sequel to those of the first dashboard described in the previous post. Combining the two, you should now be equipped to create a user interface of moderately high complexity, capable of launching a large variety of command line Python programs.

 

 

(1) To repcap on an earlier footnote, 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.

The grid() geometry for Tkinter works in almost the same way in laying out your widgets, but in two dimensions on the screen. 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.

If you’re still not sure what is happening, a great introductory tutorial on Tkinter layout management can be found here. It’s worth checking out anyway, just to gain an understanding of what’s possible with your own GUI.

(2) The Tkinter implementation on Linux Mint was found to have a bug for multiple file selection, but a workaround was included in the code.

     

Leave a Reply

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