I have a github repository where I uploaded the code of a sample Python-tkinter application for illustrate how to do a GUI with i18n following the MVC pattern and good practices.
The thing is that I recently added an automated method for building a top menu in the application in a way that you just have to create a very dict with a very intuitive menu structure and put the callback handlers inside for having a menu.
The problem is that it is a very large class now and I think the design can be improved A LOT (see the file in the repo under the feature/menu branch).
I would abstract it in a couple of ways, but the handicap is that it should be fairly easy so that people could use this project to make their own tkinter-app-skeleton with their menu and having the callback handlers in the dict makes it difficult to separe the menu_struct variable from the GUIView class.
Also, the recursive function process_menu_unit() needs access to the tk.Tk() instance in GuiView.root (self.root inside the class). So we only need to re-structure self.build_menu() and the functions and variables used by self.build_menu(), which I already mentioned.
For the sake of completeness, I'm putting here the code for the main file with some comments, but I would recommend to visit the github page:
import os
import sys
from gettext import translation
from pathlib import Path
import tkinter as tk
from tkinter import ttk, messagebox
from common import deputils
from common.constants import LOCALE_DIR
from common.events.events import SetPathEvent
from common.exceptions.exceptions import MvcError
from common.observer import Observer
from controller.controller import Controller
from controller.testcontroller import MockController
from view.dialogs import LayoutDialog, LanguageDialog
from view.icons import IconProvider
from view.widgets.tooltip import Tooltip
# Simple handlers for illustrating
def about_app():
messagebox.showinfo("App", "The application\nthat does nothing")
def are_you_sure(event=None):
if messagebox.askyesno("", "Are you sure you want to quit the App?"):
messagebox.showinfo('Peche da ventá', 'Isto pecharía a ventá') # window.destroy()
def open_file():
messagebox.showinfo("Open doc", "We'll open a file here...")
# This dict represents the menu from the top level options to the more nested ones
# I use names beginning with '__' for default tkinter properties/names that can
# used with widgets to configure them
# This dict comes with the callback handlers
menu_struct = {
'File': {
'__menu': True,
'Open': open_file,
'Open recent file...': {
'__menu': True,
'file1': open_file,
'file2': open_file,
},
'Quit': {
'__command': are_you_sure,
'__accelerator': 'Ctrl-Q',
'__underline': 1
},
},
'Edit': {
'__menu': True,
'Replace': lambda: messagebox.showinfo('App', 'Replaces everything'),
'Load file': lambda: messagebox.showinfo('App', 'Load a file...'),
},
'About': {
'__command': about_app
},
}
def process_menu_unit(context_to_add, menu, **kwargs):
"""
Recursively builds the menu with the context dict received over a tkinter menu_component.
With each recursive call:
- the context_dict shrinks because some of its elements have been built on the menu_component
- the menu_component grows and is bigger, because more of its elements have been built
:param context_to_add: the context dict with the menu options with processing still pending
:param menu_component: the visual tkinter menu component where more elements will be added
"""
if not isinstance(context_to_add, dict):
return
for key, value in context_to_add.items():
if not key.startswith('__'):
if callable(value):
menu.add_command(label=key, command=value)
elif isinstance(value, dict):
options = {'label': key}
options_set = {opt_name[2:]: opt_value for opt_name, opt_value in value.items() if opt_name.startswith('__')}
for optionkey, optionvalue in options_set.items():
if optionkey == 'menu':
options['menu'] = tk.Menu(menu, tearoff=False)
else:
options[optionkey] = optionvalue
if 'command' in options:
menu.add_command(**options)
elif isinstance(value, dict) and 'menu' in options:
menu.add_cascade(**options)
process_menu_unit(value, **options)
# For brevity, I'm not putting here some code for handling dependencies
class GuiView(ttk.Frame, Observer):
ROW_MINSIZE = 5
COL_MINSIZE = 35
PADDING = 5
ICON_PLACE = 'right'
def __init__(self, controller: Controller, *args, **kwargs):
self.root = tk.Tk()
# Sets the app icon
self.root.tk.call('wm', 'iconphoto', self.root._w, IconProvider.get('save_disk'))
#self.root.bind("<Button-1>", lambda e: self.root.destroy())
self.root.withdraw()
d = LanguageDialog(self.root)
self.selected_language = d.selected_language
lang = translation('mvcPython', LOCALE_DIR, languages=[d.get_selected_locale()], fallback=True)
lang.install()
super().__init__(master=self.root, *args, **kwargs)
self.controller = controller
self.selected_profile = tk.StringVar()
self.destiny_path = tk.StringVar()
self.destiny_path.set(f'{Path.home()}')
# This builds the menu!
self.build_menu()
# Dynamic resizing
"""
Col0 Col1 Col2 Col3 Col4
Row 0 ----------- Create -----------
Row 1 Profile Cmb Entry SaveTo
Row 2 --- Add ---- -------------- Yscr
Row 3 --- Rmv ---- ---- List ---- Yscr
Row 4 -- Clear --- -------------- Yscr
Row 5 ---- Xscr ----
Row 6 ---------- Collect -----------
Row 7 ----------- Text ------------- Yscr
Row 8 ----------- Xscr ------------- Yscr
"""
rows = (0, 0, 0,
0, 0, 0,
0, 1, 0)
columns = (0, 0, 1, 0, 0)
for numrow, weight in enumerate(rows):
self.rowconfigure(numrow, weight=weight, minsize=GuiView.ROW_MINSIZE)
for numcol, weight in enumerate(columns):
self.columnconfigure(numcol, weight=weight, minsize=GuiView.COL_MINSIZE)
self.pack(fill=tk.BOTH, expand=tk.TRUE)
# Widgets
folder_img = IconProvider.get('open_folder')
btn_dialog = ttk.Button(self, text=_('Open dialog'),
image=folder_img, compound=GuiView.ICON_PLACE,
command=self._on_btn_dialog)
Tooltip(btn_dialog, text=_('this button opens a dialog'))
btn_dialog.grid(row=0, column=0, columnspan=5,
sticky='ew', padx=GuiView.PADDING, pady=GuiView.PADDING)
ops = Controller.get_combo_options()
ttk.Label(self, text=_('Simple label')).grid(row=1, column=0, padx=GuiView.PADDING, pady=GuiView.PADDING)
cmb_profile = ttk.Combobox(self, values=ops, state='readonly',
textvariable=self.selected_profile)
Tooltip(cmb_profile, text=_('combobox for selecting values'))
cmb_profile.grid(row=1, column=1, sticky='we',
padx=GuiView.PADDING, pady=GuiView.PADDING)
path_entry = ttk.Entry(self, textvariable=self.destiny_path,
state='readonly')
path_entry.grid(row=1, column=2, sticky='we',
padx=GuiView.PADDING, pady=GuiView.PADDING)
# We use an icon in this button
save_disk_img = IconProvider.get('save_disk')
btn_saveto = ttk.Button(self, image=save_disk_img,
text=_('Save'), compound=GuiView.ICON_PLACE,
command=self._on_open_save_dialog)
Tooltip(btn_saveto, text=_('button for saving things'))
btn_saveto.grid(row=1, column=3, columnspan=2, sticky='we',
padx=GuiView.PADDING, pady=GuiView.PADDING)
btn_add_values = ttk.Button(self, image=folder_img,
compound=GuiView.ICON_PLACE,
text=_('Add value'),
command=self._on_open_collectd_dialog)
Tooltip(btn_add_values, text=_('button for add values'))
btn_add_values.grid(row=2, column=0, columnspan=2,sticky='nswe',
padx=GuiView.PADDING, pady=GuiView.PADDING)
garbage_bin_img = IconProvider.get('garbage_bin')
btn_remove_metric_paths = ttk.Button(self, image=garbage_bin_img, compound=GuiView.ICON_PLACE,
text=_('Remove values'),
command=self._on_remove_collectd_selection)
Tooltip(btn_remove_metric_paths, text=_('this button removes values'))
btn_remove_metric_paths.grid(row=3, column=0, columnspan=2,
sticky='nswe', padx=GuiView.PADDING, pady=GuiView.PADDING)
btn_clear_metric_paths = ttk.Button(self, image=garbage_bin_img, compound=GuiView.ICON_PLACE,
text=_('Clear'),
command=self._on_clear_collectd_selection)
Tooltip(btn_clear_metric_paths, text=_('this button clears all'))
btn_clear_metric_paths.grid(row=4, column=0, columnspan=2,
sticky='nswe', padx=GuiView.PADDING, pady=GuiView.PADDING)
# Listbox with dynamic vertical scroll
xscr = ttk.Scrollbar(self, orient=tk.HORIZONTAL)
xscr.grid(row=5, column=2, rowspan=1, columnspan=2, sticky='WE')
yscr = ttk.Scrollbar(self, orient=tk.VERTICAL)
yscr.grid(row=2, rowspan=3, column=4, sticky='NS')
self.lst_path = tk.Listbox(self, height=5,
xscrollcommand=xscr.set, yscrollcommand=yscr.set)
self.lst_path.grid(row=2, column=2, rowspan=3, columnspan=2,
sticky='nswe', padx=GuiView.PADDING, pady=GuiView.PADDING)
xscr['command'] = self.lst_path.xview
yscr['command'] = self.lst_path.yview
metrics_img = IconProvider.get('metrics')
btn_collect = ttk.Button(self, text=_('Do things'),
image=metrics_img, compound=GuiView.ICON_PLACE,
command=self._on_collect_metrics)
Tooltip(btn_collect, text=_('this button gets the stuff done'))
btn_collect.grid(row=6, column=0, columnspan=5,
sticky='we', padx=GuiView.PADDING, pady=GuiView.PADDING)
# Text widget with dynamic scroll
xscr = ttk.Scrollbar(self, orient=tk.HORIZONTAL)
xscr.grid(row=8, column=0, columnspan=4, sticky='WE')
yscr = ttk.Scrollbar(self, orient=tk.VERTICAL)
yscr.grid(row=7, column=4, sticky='NS')
self.output = tk.Text(self, height=8,
yscrollcommand=yscr.set,
xscrollcommand=xscr.set)
self.output.configure(state=tk.DISABLED)
self.output.grid(row=7, column=0, columnspan=4,
sticky='nswe', padx=GuiView.PADDING, pady=GuiView.PADDING)
yscr['command'] = self.output.yview
xscr['command'] = self.output.xview
def build_menu(self):
main_menu = tk.Menu(self.root)
self.root.config(menu=main_menu)
process_menu_unit(menu_struct, **{'menu': main_menu})
def init_ui(self):
"""
Shows the UI
"""
self.root.title(_('MyProject'))
self.root.iconify()
self.root.update()
self.root.deiconify()
self.root.minsize(self.root.winfo_width(), self.root.winfo_height())
self.root.mainloop()
def start(self):
"""
Shows the already initialized and built UI.
__init__ should be called previously
"""
self.init_ui()
def refresh(self, value):
msg = '\n'
if isinstance(value, str):
msg += value
elif isinstance(value, MvcError):
msg += value.messages[0]
messagebox.showerror('Error', msg)
elif isinstance(value, SetPathEvent):
self.destiny_path.set(value.info)
if msg.strip():
self.output.config(state=tk.NORMAL)
self.output.insert(tk.END, msg)
self.output.config(state=tk.DISABLED)
def _on_collect_metrics(self):
"""
Handles event when the collect metrics button is pressed
:param __: the event
"""
self.controller.collect_metrics(self.destiny_path.get(),
self.lst_path.get(0, tk.END),
self.selected_profile.get())
def _on_open_save_dialog(self, __=None):
"""
Handles event when the save button is pressed
:param __: the event
"""
self.destiny_path.set(
tkfilebrowser.askopendirname(initialdir=self.destiny_path.get(),
title=_('Please, save file on your desired path'))
)
def _on_open_collectd_dialog(self, __=None):
"""
Handles event when the add metrics button is pressed
:param __: the event
"""
initial_dir = ''
dest_path = self.destiny_path.get()
if dest_path and dest_path.strip():
initial_dir = os.path.dirname(dest_path)
upper_selected_profile = self.selected_profile.get().upper()
top_title_msg = _('Profile {}').format(upper_selected_profile)
collectd_paths = tkfilebrowser.askopendirnames(initialdir=initial_dir,
title=top_title_msg)
for p in collectd_paths:
self.lst_path.insert(tk.END, p)
def _on_remove_collectd_selection(self, __=None):
"""
Handles event when the remove metrics path button is pressed
:param __: the event
"""
for idx in self.lst_path.curselection():
self.lst_path.delete(idx)
def _on_clear_collectd_selection(self, __=None):
"""
Handles event when the clear button is pressed
:param __: the event
"""
self.lst_path.delete(0, tk.END)
def _on_btn_dialog(self, __=None):
"""
Handles event when the clear button is pressed
:param __: the event
"""
try:
d = LayoutDialog(self)
except MvcError as ge:
self.refresh(ge)
class TerminalView(Observer):
def __init__(self, controller: Controller, *args, **kwargs):
self.controller = controller
lang = translation('mvcPython', LOCALE_DIR, languages=['en'], fallback=True)
lang.install()
def start(self):
"""
Shows the prompt.
"""
while True:
cmd_in = input('> ')
if cmd_in in self.controller.__class__.__dict__ and callable(self.controller.__class__.__dict__[cmd_in]):
self.controller.__class__.__dict__[cmd_in](self.controller)
def refresh(self, value):
print(value)
if __name__ == '__main__':
v = GuiView(MockController())
v.init_ui()