How to intelligently apply DRY principles to tkinter OptionMenu?

I have a simple tkinter app that works fine right now, but the code is not well written. My primary issue is that it seems that each OptionMenu needs to have its own tkvar and own testFunc in order to have the app behave the way I want it to. It doesn't seem like I can call additional variables in the command section, which is why it's hard for me to consolidate this code.

The purpose of the app is to allow the user to select the order of the animals and display that order right away. Hoping someone can illuminate the light for me to make this code more DRY and more intelligent.

import tkinter as tk
from tkinter import ttk

class SampleApp(tk.Tk):
    def __init__(self, *args, **kwargs):
        tk.Tk.__init__(self, *args, **kwargs)
        self.wm_title("Choose Multiple Animals")
        self._frame = None

class AnimalPage(ttk.Frame):
    def __init__(self, master, controller):
        tk.Frame.__init__(self, master)
        self.master = master
        self.config(relief='sunken', borderwidth=2)
        self.pack(fill = "both", expand = False)
        self.grid_rowconfigure(0, weight = 1)
        self.grid_columnconfigure(0, weight = 1)

        self.animalList = ['Cat', 'Dog', 'Bear']
        self.choices = ['None', 'Animal1', 'Animal2', 'Animal3']
        self.tkvar1 = tk.StringVar(master)
        self.tkvar1.set('None')
        self.tkvar2 = tk.StringVar(master)
        self.tkvar2.set('None')
        self.tkvar3 = tk.StringVar(master)
        self.tkvar3.set('None')
        self.tkvar4 = tk.StringVar()

        self.textLabel1 = ttk.Label(self, text=self.animalList[0])
        self.textLabel1.grid(column=0, row = 5, sticky = (tk.W), padx=5, pady=5)
        self.popupMenu1 = ttk.OptionMenu(self, self.tkvar1, *self.choices, command=self.testFunc1)
        self.popupMenu1.grid(column=1, row = 5, sticky = (tk.W, tk.E), padx=5, pady=5)
        self.textLabel2 = ttk.Label(self, text=self.animalList[1])
        self.textLabel2.grid(column=0, row = 6, sticky = (tk.W), padx=5, pady=5)
        self.popupMenu2 = ttk.OptionMenu(self, self.tkvar2, *self.choices, command=self.testFunc2)
        self.popupMenu2.grid(column=1, row = 6, sticky = (tk.W, tk.E), padx=5, pady=5)
        self.textLabel3 = ttk.Label(self, text=self.animalList[2])
        self.textLabel3.grid(column=0, row = 7, sticky = (tk.W), padx=5, pady=5)
        self.popupMenu3 = ttk.OptionMenu(self, self.tkvar3, *self.choices, command=self.testFunc3)
        self.popupMenu3.grid(column=1, row = 7, sticky = (tk.W, tk.E), padx=5, pady=5)
        self.chosenAnimals = {}

        self.textLabel4 = ttk.Label(self, text=self.tkvar4.get())
        self.textLabel4.grid(column=0, row = 8, sticky = (tk.W, tk.E), padx=5, pady=5)

    def testFunc1(self, value):
        self.chosenAnimals.update({value: self.animalList[0]})
        self.configure()

    def testFunc2(self, value):
        self.chosenAnimals.update({value: self.animalList[1]})
        self.configure()

    def testFunc3(self, value):
        self.chosenAnimals.update({value: self.animalList[2]})
        self.configure()

    def configure(self):
        self.printout = ["{} is the {}".format(k, v) for (k,v) in self.chosenAnimals.items()]
        self.tkvar4.set(self.printout)
        self.textLabel4.config(text = self.tkvar4.get())

if __name__ == "__main__":
    app = SampleApp()
    newFrame = AnimalPage(app, app)
    app.geometry("500x200")
    app.mainloop()

1 answer

  • answered 2018-10-09 16:38 Idlehands

    Make use of arrays or dictionaries:

    import tkinter as tk
    from tkinter import ttk
    
    class SampleApp(tk.Tk):
        def __init__(self, *args, **kwargs):
            tk.Tk.__init__(self, *args, **kwargs)
            self.wm_title("Choose Multiple Animals")
            self._frame = None
    
    class AnimalPage(ttk.Frame):
        def __init__(self, master, controller):
            tk.Frame.__init__(self, master)
            self.master = master
            self.config(relief='sunken', borderwidth=2)
            self.pack(fill = "both", expand = False)
            self.grid_rowconfigure(0, weight = 1)
            self.grid_columnconfigure(0, weight = 1)
    
            self.animalList = ['Cat', 'Dog', 'Bear']
            self.choices = ['None', 'Animal1', 'Animal2', 'Animal3']
    
            self.animal_vars = dict()
            self.text_labels = dict()
            self.popup_menus = dict()
            self.chosenAnimals = {}
            self.tkvar4 = tk.StringVar()
    
            for i, animal in enumerate(self.animalList):
                self.animal_vars[animal] = tk.StringVar(master)
                self.animal_vars[animal].set('None')
                self.text_labels[animal] = ttk.Label(self, text=animal)
                self.text_labels[animal].grid(column=0, row = 5 + i, sticky = (tk.W), padx=5, pady=5)
                self.popup_menus[animal] = ttk.OptionMenu(self, self.animal_vars[animal], *self.choices, command=lambda selected, my_animal=animal: self.testFunc(my_animal, selected))
                self.popup_menus[animal].grid(column=1, row = 5 + i, sticky = (tk.W, tk.E), padx=5, pady=5)
    
            self.textLabel4 = ttk.Label(self, text=self.tkvar4.get())
            self.textLabel4.grid(column=0, row = 8, sticky = (tk.W, tk.E), padx=5, pady=5)      
    
        def testFunc(self, animal, selection):
            self.chosenAnimals.update({animal: selection})
            self.configure()
    
        def configure(self):
            self.printout = ["{} is the {}".format(k, v) for (k,v) in self.chosenAnimals.items()]
            self.tkvar4.set(self.printout)
            self.textLabel4.config(text = self.tkvar4.get())
    

    Since you're basically iterating through the animalList to create the Labels and OptionMenus dynamically, you might as well use a dict or list to help you manage and iterate through the objects.

    Once you have the dict or list set up, you can now assign/append the tk widgets you created into it and reference back easily. In your example personally I prefer dict since each animal has a meaningful name to go with and easier to debug (looking for self.text_labels['Cat'] would be easier than self.text_labels[0])

    Also, you can make use of lambda to bypass the limitation of command=... in tk widgets. This way you pass the animal name right back to the function so you don't need to define it for each animal.

    As an aside, ideally I would suggest you give your objects more meaningful names. Stay away from terms like textLabel4 or tkvar4 so it's easier to understand the code.

    Important Note:

    For the lambda to work in the loop you will need the iterated animal to be a default param instead of directly inside the lambda, a quick demo:

    def func(v):
        print(v)
    
    x = list(range(3))
    for i in range(len(x)):
        x[i] = lambda: func(i)
    
    x[0]
    # 2
    

    You might expect x[0] will result in printing 0, but in reality it will be 2, and it will be the same result across x[0:2]. The reason is that when the lambda is assigned, it's referencing the i object instead of its value of [0, 1, 2] in each iteration. Therefore since the loop ended, i = 2, and your x funcs will always print 2.

    However, if you passed i as a default param in the lambda, the value will be passed:

    x[i] = lambda y=i: func(y)
    
    x[0]()
    # 0
    

    In conjunction with this fact, the reason why I used lambda selected, my_animal=animal:... is due to the command=... in the OptionMenu is always passing its variable (which in this case is the chosen Animal1, Animal2...) as the first param of the function.

    Hope this clears things a bit.