DjH DjH - 2 months ago 24
Python Question

Tkinter: Only allow one Toplevel window instance

I have a tkinter program with multiple windows. Here is the full code in case it's entirety is needed.

import tkinter as tk
import tkinter.scrolledtext as tkst
from tkinter import ttk
import logging
import time


def popupmsg(msg):
popup = tk.Toplevel()
popup.wm_title("!")
label = ttk.Label(popup, text=msg)
label.pack(side="top", fill="x", pady=10)
b1 = ttk.Button(popup, text="Okay", command=popup.destroy)
b1.pack()
popup.mainloop()


def test1():
root.logger.error("Test")


def toggle(self):
t_btn = self.t_btn
if t_btn.config('text')[-1] == 'Start':
t_btn.config(text='Stop')

def startloop():
if root.flag:
now = time.strftime("%c")
root.logger.error(now)
root.after(30000, startloop)
else:
root.flag = True
return
startloop()
else:
t_btn.config(text='Start')
root.logger.error("Loop stopped")
root.flag = False


class TextHandler(logging.Handler):

def __init__(self, text):
# run the regular Handler __init__
logging.Handler.__init__(self)
# Store a reference to the Text it will log to
self.text = text

def emit(self, record):
msg = self.format(record)

def append():
self.text.configure(state='normal')
self.text.insert(tk.END, msg + '\n')
self.text.configure(state='disabled')
# Autoscroll to the bottom
self.text.yview(tk.END)

# This is necessary because we can't modify the Text from other threads
self.text.after(0, append)

def create(self):
# Create textLogger
topframe = tk.Frame(root)
topframe.pack(side=tk.TOP)

st = tkst.ScrolledText(topframe, bg="#00A09E", fg="white", state='disabled')
st.configure(font='TkFixedFont')

st.pack()

self.text_handler = TextHandler(st)

# Add the handler to logger
root.logger = logging.getLogger()
root.logger.addHandler(self.text_handler)

def stop(self):
root.flag = False

def start(self):
if root.flag:
root.logger.error("error")
root.after(1000, self.start)
else:
root.logger.error("Loop stopped")
root.flag = True
return

def loop(self):
self.start()


class HomePage(tk.Frame):

def __init__(self, parent):
tk.Frame.__init__(self, parent)

container = tk.Frame(self)
container.pack(side="top", fill="both", expand=True)
container.grid_rowconfigure(0, weight=1)
container.grid_columnconfigure(0, weight=1)

self.menubar = tk.Menu(container)

# Create taskbar/menu
file = tk.Menu(self.menubar)
file.add_command(label="Run", command=lambda: test1())
file.add_command(label="Stop", command=lambda: test1())
file.add_separator()
file.add_command(label="Settings", command=lambda: Settings())
file.add_separator()
file.add_command(label="Quit", command=quit)
self.menubar.add_cascade(label="File", menu=file)

self.master.config(menu=self.menubar)

#logger and main loop
th = TextHandler("none")
th.create()
root.flag = True
root.logger.error("Welcome to ShiptScraper!")

bottomframe = tk.Frame(self)
bottomframe.pack(side=tk.BOTTOM)

topframe = tk.Frame(self)
topframe.pack(side=tk.TOP)

self.t_btn = tk.Button(text="Start", highlightbackground="#56B426", command=lambda: toggle(self))
self.t_btn.pack(pady=5)

self.exitButton = tk.Button(text="Exit", highlightbackground="#56B426", command=quit)
self.exitButton.pack()
root.setting = False


class Settings(tk.Toplevel):

def __init__(self, master=None):
tk.Toplevel.__init__(self, master)
self.wm_title("Settings")
print(Settings.state(self))

exitButton = tk.Button(self, text="Exit", highlightbackground="#56B426", command=self.destroy)
exitButton.pack()


class Help(tk.Toplevel):

def __init__(self, parent):
tk.Toplevel.__init__(self, parent)
self.wm_title("Help")

exitButton = tk.Button(text="Exit", highlightbackground="#56B426", command=quit)
exitButton.pack()


if __name__ == "__main__":
root = tk.Tk()
root.configure(background="#56B426")
root.wm_title("ShiptScraper")
app = HomePage(root)
app.mainloop()


Basically my problem is that clicking the command
Settings
from the menu brings up a new
Settings
window each time it's clicked. I can't figure out how make it so it can detect if one window instance is already open or not. I've tried using
state()
as a check in a method in the
HomePage
class like

#in it's respective place as shown above
file.add_command(label="Settings", command=lambda: self.open(Settings))

#outside the init as a method
def open(self, window):
if window.state(self) != 'normal':
window()


This returns this error

Exception in Tkinter callback
Traceback (most recent call last):
File "/Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/tkinter/__init__.py", line 1550, in __call__
return self.func(*args)
File "/Users/user/pythonProjects/ShiptScraper/ShiptScraperGUI.py", line 112, in <lambda>
file.add_command(label="Settings", command=lambda: self.open(Settings))
File "/Users/user/pythonProjects/ShiptScraper/ShiptScraperGUI.py", line 139, in open
if window.state(self) != 'normal':
File "/Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/tkinter/__init__.py", line 1826, in wm_state
return self.tk.call('wm', 'state', self._w, newstate)
_tkinter.TclError: window ".4319455216" isn't a top-level window


I've tried using the
winfo_exists()
method, but it seems like unless I've already destroyed the window (which I haven't if it hasn't been opened yet) this won't do me any good. Nonetheless here's one of those combo's I've tried

def open(self, window):
if window.winfo_exists(self) != 1:
window()


This of course does nothing. I'm not going to go through every other wrong combination. I've tried because honestly at this point, I can't remember them all.

I have also tried defining these
open
methods as functions outside of any class and they don't work there either, usually because of confusion of
self
keywords not being defined outside a class, but needing to be a parameter of the
winfo_exists()
and
state()
methods.

I'm also thinking that my problem, in using these functions as methods in the HomePage class, is because whenever I'm referencing
self
, it's checking
HomePage
, not whatever window I'm passing as an argument in the method. I'm not really sure though, which is why I'm here.

Really, what I'm trying to do is just create a standard method in my
HomePage
window that controls how the menu (and possibly buttons later) opens a window. This would logically (in my own psuedocode) be:

def open(window)
if window does not exist:
open an instance of window


Is this possible, Or is there a better approach to window management I should be taking?

Edit:
I originally neglected to mention that my OS is Mac OSX running Mavericks. Apparently this may be an OSX issue. Also, if you're going to downvote this question at least comment and tell me why/how I can revise it to make it better.

I've now tried these combinations

class Settings(tk.Toplevel):

def __init__(self, master=None):
tk.Toplevel.__init__(self, master)
self.wm_title("Settings")
# added grab_set()
self.grab_set()
#
print(Settings.state(self))

exitButton = tk.Button(self, text="Exit", highlightbackground="#56B426", command=self.destroy)
exitButton.pack()


and

class Settings(tk.Toplevel):

def __init__(self, master=None):
tk.Toplevel.__init__(self, master)
self.wm_title("Settings")
# added grab_set()
self.grab_set()
self.focus()
#
print(Settings.state(self))

exitButton = tk.Button(self, text="Exit", highlightbackground="#56B426", command=self.destroy)
exitButton.pack()


and

class Settings(tk.Toplevel):

def __init__(self, master=None):
tk.Toplevel.__init__(self, master)
self.wm_title("Settings")
# added grab_set()
self.attributes("-topmost", True)
#
print(Settings.state(self))

exitButton = tk.Button(self, text="Exit", highlightbackground="#56B426", command=self.destroy)
exitButton.pack()


and

class Settings(tk.Toplevel):

def __init__(self, master=None):
tk.Toplevel.__init__(self, master)
self.wm_title("Settings")
# added grab_set()
self.after(1, lambda: self.focus_force())

#
print(Settings.state(self))

exitButton = tk.Button(self, text="Exit", highlightbackground="#56B426", command=self.destroy)
exitButton.pack()


Edit #2:

I've come up with a workaround... I hate it. But it works, at least for now. I'm definitely still hoping for a better solution.

import tkinter as tk
import tkinter.scrolledtext as tkst
from tkinter import ttk
import logging
import time



def popupmsg(msg):
popup = tk.Toplevel()
popup.wm_title("!")
label = ttk.Label(popup, text=msg)
label.pack(side="top", fill="x", pady=10)
b1 = ttk.Button(popup, text="Okay", command=popup.destroy)
b1.pack()
popup.mainloop()


def test1():
root.logger.error("Test")


def toggle(self):
t_btn = self.t_btn
if t_btn.config('text')[-1] == 'Start':
t_btn.config(text='Stop')

def startloop():
if root.flag:
now = time.strftime("%c")
root.logger.error(now)
root.after(30000, startloop)
else:
root.flag = True
return
startloop()
else:
t_btn.config(text='Start')
root.logger.error("Loop stopped")
root.flag = False


class TextHandler(logging.Handler):

def __init__(self, text):
# run the regular Handler __init__
logging.Handler.__init__(self)
# Store a reference to the Text it will log to
self.text = text

def emit(self, record):
msg = self.format(record)

def append():
self.text.configure(state='normal')
self.text.insert(tk.END, msg + '\n')
self.text.configure(state='disabled')
# Autoscroll to the bottom
self.text.yview(tk.END)

# This is necessary because we can't modify the Text from other threads
self.text.after(0, append)

def create(self):
# Create textLogger
topframe = tk.Frame(root)
topframe.pack(side=tk.TOP)

st = tkst.ScrolledText(topframe, bg="#00A09E", fg="white", state='disabled')
st.configure(font='TkFixedFont')

st.pack()

self.text_handler = TextHandler(st)

# Add the handler to logger
root.logger = logging.getLogger()
root.logger.addHandler(self.text_handler)

def stop(self):
root.flag = False

def start(self):
if root.flag:
root.logger.error("error")
root.after(1000, self.start)
else:
root.logger.error("Loop stopped")
root.flag = True
return

def loop(self):
self.start()


class HomePage(tk.Frame):

def __init__(self, parent):
tk.Frame.__init__(self, parent)

container = tk.Frame(self)
container.pack(side="top", fill="both", expand=True)
container.grid_rowconfigure(0, weight=1)
container.grid_columnconfigure(0, weight=1)

# NEW added a flag for the Settings window
root.settings = False
self.menubar = tk.Menu(container)

# Create taskbar/menu
file = tk.Menu(self.menubar)
file.add_command(label="Run", command=lambda: test1())
file.add_command(label="Stop", command=lambda: test1())
file.add_separator()

# NEW now calling a method from Settings instead of Settings itself
file.add_command(label="Settings", command=lambda: Settings().open())
file.add_separator()
file.add_command(label="Quit", command=quit)
self.menubar.add_cascade(label="File", menu=file)

self.master.config(menu=self.menubar)

#logger and main loop
th = TextHandler("none")
th.create()
root.flag = True
root.logger.error("Welcome to ShiptScraper!")

bottomframe = tk.Frame(self)
bottomframe.pack(side=tk.BOTTOM)

topframe = tk.Frame(self)
topframe.pack(side=tk.TOP)

self.t_btn = tk.Button(text="Start", highlightbackground="#56B426", command=lambda: toggle(self))
self.t_btn.pack(pady=5)

self.exitButton = tk.Button(text="Exit", highlightbackground="#56B426", command=quit)
self.exitButton.pack()
root.setting = False


class Settings(tk.Toplevel):

def __init__(self, master=None):
tk.Toplevel.__init__(self, master)

# NEW 'open' method which is being called. This checks the root.setting flag added in the HomePage class
def open(self):
#NEW if root setting is false, continue creation of of Settings window
if not root.setting:
self.wm_title("Settings")
# added grab_set()
Settings.grab_set(self)

#NEW edited the exitButton command, see close function below
exitButton = tk.Button(self, text="Exit", highlightbackground="#56B426", command=lambda: close())
exitButton.pack()
root.setting = True
#NEW if the root.settings flag is TRUE this cancels window creation
else:
self.destroy()

#NEW when close() is called it resets the root.setting flag to false, then destroys the window
def close():
root.setting = False
self.destroy()


class Help(tk.Toplevel):

def __init__(self, parent):
tk.Toplevel.__init__(self, parent)
self.wm_title("Help")

exitButton = tk.Button(text="Exit", highlightbackground="#56B426", command=quit)
exitButton.pack()


if __name__ == "__main__":
root = tk.Tk()
root.configure(background="#56B426")
root.wm_title("ShiptScraper")
app = HomePage(root)
app.mainloop()


This feels like a complete and utter hack, I feel dirty looking at it and even dirtier for creating this abomination... but it works, for now at least

EDIT 3:

Added the protocol for window close in Jacob's answer. Had forgotten to account for that. This is the last version I'll share unless I come up with a better approach.

import tkinter as tk
import tkinter.scrolledtext as tkst
from tkinter import ttk
import logging
import time



def popupmsg(msg):
popup = tk.Toplevel()
popup.wm_title("!")
label = ttk.Label(popup, text=msg)
label.pack(side="top", fill="x", pady=10)
b1 = ttk.Button(popup, text="Okay", command=popup.destroy)
b1.pack()
popup.mainloop()


def test1():
root.logger.error("Test")


def toggle(self):
t_btn = self.t_btn
if t_btn.config('text')[-1] == 'Start':
t_btn.config(text='Stop')

def startloop():
if root.flag:
now = time.strftime("%c")
root.logger.error(now)
root.after(30000, startloop)
else:
root.flag = True
return
startloop()
else:
t_btn.config(text='Start')
root.logger.error("Loop stopped")
root.flag = False


class TextHandler(logging.Handler):

def __init__(self, text):
# run the regular Handler __init__
logging.Handler.__init__(self)
# Store a reference to the Text it will log to
self.text = text

def emit(self, record):
msg = self.format(record)

def append():
self.text.configure(state='normal')
self.text.insert(tk.END, msg + '\n')
self.text.configure(state='disabled')
# Autoscroll to the bottom
self.text.yview(tk.END)

# This is necessary because we can't modify the Text from other threads
self.text.after(0, append)

def create(self):
# Create textLogger
topframe = tk.Frame(root)
topframe.pack(side=tk.TOP)

st = tkst.ScrolledText(topframe, bg="#00A09E", fg="white", state='disabled')
st.configure(font='TkFixedFont')

st.pack()

self.text_handler = TextHandler(st)

# Add the handler to logger
root.logger = logging.getLogger()
root.logger.addHandler(self.text_handler)

def stop(self):
root.flag = False

def start(self):
if root.flag:
root.logger.error("error")
root.after(1000, self.start)
else:
root.logger.error("Loop stopped")
root.flag = True
return

def loop(self):
self.start()


class HomePage(tk.Frame):

def __init__(self, parent):
tk.Frame.__init__(self, parent)

container = tk.Frame(self)
container.pack(side="top", fill="both", expand=True)
container.grid_rowconfigure(0, weight=1)
container.grid_columnconfigure(0, weight=1)

# NEW added a flag for the Settings window
root.setting = True
self.menubar = tk.Menu(container)

# Create taskbar/menu
file = tk.Menu(self.menubar)
file.add_command(label="Run", command=lambda: test1())
file.add_command(label="Stop", command=lambda: test1())
file.add_separator()

# NEW now calling a method from Settings instead of Settings itself
file.add_command(label="Settings", command=lambda: Settings().open())
file.add_separator()
file.add_command(label="Quit", command=quit)
self.menubar.add_cascade(label="File", menu=file)

self.master.config(menu=self.menubar)

#logger and main loop
th = TextHandler("none")
th.create()
root.flag = True
root.logger.error("Welcome to ShiptScraper!")

bottomframe = tk.Frame(self)
bottomframe.pack(side=tk.BOTTOM)

topframe = tk.Frame(self)
topframe.pack(side=tk.TOP)

self.t_btn = tk.Button(text="Start", highlightbackground="#56B426", command=lambda: toggle(self))
self.t_btn.pack(pady=5)

self.exitButton = tk.Button(text="Exit", highlightbackground="#56B426", command=quit)
self.exitButton.pack()


class Settings(tk.Toplevel):

def __init__(self, master=None):
tk.Toplevel.__init__(self, master)

# NEW 'open' method which is being called. This checks the root.setting flag added in the HomePage class
def open(self):
#NEW when close() is called it resets the root.setting flag to false, then destroys the window
def close_TopLevel():
root.setting = True
self.destroy()

#NEW if root setting is false, continue creation of of Settings window
if root.setting:
self.wm_title("Settings")
#NEW adjust window close protocol and change root.setting to FALSE
self.protocol('WM_DELETE_WINDOW', close_TopLevel)
root.setting = False

#NEW edited the exitButton command, see close function below
exitButton = tk.Button(self, text="Exit", highlightbackground="#56B426", command=lambda: close_TopLevel())
exitButton.pack()

#NEW if the root.settings flag is TRUE this cancels window creation
else:
print('shit')
self.destroy()



class Help(tk.Toplevel):

def __init__(self, parent):
tk.Toplevel.__init__(self, parent)
self.wm_title("Help")

exitButton = tk.Button(text="Exit", highlightbackground="#56B426", command=quit)
exitButton.pack()


if __name__ == "__main__":
root = tk.Tk()
root.configure(background="#56B426")
root.wm_title("ShiptScraper")
app = HomePage(root)
app.mainloop()

Answer

tkinter's grab_set() is exactly made for that.

Change the code section below into:

class Settings(tk.Toplevel):

    def __init__(self, master=None):
        tk.Toplevel.__init__(self, master)
        self.wm_title("Settings")
        # added grab_set()
        self.grab_set()
        #
        print(Settings.state(self))

        exitButton = tk.Button(self, text="Exit", highlightbackground="#56B426", command=self.destroy)
        exitButton.pack()

Now when you open the settings window, the main window will not react on button clicks while the settings window exists.

See also here.


EDIT

Trickery and deceit

Since there seems to be a bug in Tkinter / OSX concerning the use of grab_set() which works fine on Linux (Ubuntu 16.04), here some trickery and deceit.

I edited your code a bit. For simplicity reasons of the example, I added the Toplevel window to the HomePage-class. I marked the changes ##.

The concept:

  • Add a variable to your class, representing the fact that the Settings window exists (or not):

    self.check = False
    
  • If the Settings window is called, the value changes:

    self.check = True
    
  • The function to call the Settings window is now passive. No additional Settings windows will appear:

     def call_settings(self):
        if self.check == False:
            self.settings_window()
    
  • We add a protocol to the Settings window, to run a command if the window stops to exist:

    self.settingswin.protocol('WM_DELETE_WINDOW', self.close_Toplevel)
    
  • Then the called function will reset self.check:

    def close_Toplevel(self):
        self.check = False
        self.settingswin.destroy()
    

    This will work no matter how the Settings window was closed.

The edited code:

import tkinter as tk
import tkinter.scrolledtext as tkst
from tkinter import ttk
import logging
import time

def popupmsg(msg):
    popup = tk.Toplevel()
    popup.wm_title("!")
    label = ttk.Label(popup, text=msg)
    label.pack(side="top", fill="x", pady=10)
    b1 = ttk.Button(popup, text="Okay", command=popup.destroy)
    b1.pack()
    popup.mainloop()

def test1():
    root.logger.error("Test")

def toggle(self):
    t_btn = self.t_btn
    if t_btn.config('text')[-1] == 'Start':
        t_btn.config(text='Stop')

        def startloop():
            if root.flag:
                now = time.strftime("%c")
                root.logger.error(now)
                root.after(30000, startloop)
            else:
                root.flag = True
                return
        startloop()
    else:
        t_btn.config(text='Start')
        root.logger.error("Loop stopped")
        root.flag = False


class TextHandler(logging.Handler):

    def __init__(self, text):
        # run the regular Handler __init__
        logging.Handler.__init__(self)
        # Store a reference to the Text it will log to
        self.text = text

    def emit(self, record):
        msg = self.format(record)

        def append():
            self.text.configure(state='normal')
            self.text.insert(tk.END, msg + '\n')
            self.text.configure(state='disabled')
            # Autoscroll to the bottom
            self.text.yview(tk.END)

        # This is necessary because we can't modify the Text from other threads
        self.text.after(0, append)

    def create(self):
        # Create textLogger
        topframe = tk.Frame(root)
        topframe.pack(side=tk.TOP)

        st = tkst.ScrolledText(topframe, bg="#00A09E", fg="white", state='disabled')
        st.configure(font='TkFixedFont')

        st.pack()

        self.text_handler = TextHandler(st)

        # Add the handler to logger
        root.logger = logging.getLogger()
        root.logger.addHandler(self.text_handler)

    def stop(self):
        root.flag = False

    def start(self):
        if root.flag:
            root.logger.error("error")
            root.after(1000, self.start)
        else:
            root.logger.error("Loop stopped")
            root.flag = True
            return

    def loop(self):
        self.start()

class HomePage(tk.Frame):

    def __init__(self, parent):
        tk.Frame.__init__(self, parent)

        container = tk.Frame(self)
        container.pack(side="top", fill="both", expand=True)
        container.grid_rowconfigure(0, weight=1)
        container.grid_columnconfigure(0, weight=1)

        self.menubar = tk.Menu(container)
        self.check = False ### new

        # Create taskbar/menu
        file = tk.Menu(self.menubar)
        file.add_command(label="Run", command=lambda: test1())
        file.add_command(label="Stop", command=lambda: test1())
        file.add_separator()
        file.add_command(label="Settings", command=self.call_settings) #### new, changed command to run the function
        file.add_separator()
        file.add_command(label="Quit", command=quit)
        self.menubar.add_cascade(label="File", menu=file)

        self.master.config(menu=self.menubar)

        #logger and main loop
        th = TextHandler("none")
        th.create()
        root.flag = True
        root.logger.error("Welcome to ShiptScraper!")

        bottomframe = tk.Frame(self)
        bottomframe.pack(side=tk.BOTTOM)

        topframe = tk.Frame(self)
        topframe.pack(side=tk.TOP)

        self.t_btn = tk.Button(text="Start", highlightbackground="#56B426", command=lambda: toggle(self))
        self.t_btn.pack(pady=5)
        self.exitButton = tk.Button(text="Exit", highlightbackground="#56B426", command=quit)
        self.exitButton.pack()
        root.setting = False

    ########## changed
    def call_settings(self):
        if self.check == False:
            self.settings_window()
    ##########

    def settings_window(self):
        self.check = True
        self.settingswin = tk.Toplevel()
        self.settingswin.wm_title("Settings") 
        self.settingswin.protocol('WM_DELETE_WINDOW', self.close_Toplevel) ##### new
        exitButton = tk.Button(self.settingswin, text="Exit", highlightbackground="#56B426", command=self.close_Toplevel)
        exitButton.pack()

    def close_Toplevel(self):
        # New, this runs when the Toplevel window closes, either by button or else
        self.check = False
        self.settingswin.destroy()

class Help(tk.Toplevel):

    def __init__(self, parent):
        tk.Toplevel.__init__(self, parent)
        self.wm_title("Help")
        exitButton = tk.Button(text="Exit", highlightbackground="#56B426", command=quit)
        exitButton.pack()

if __name__ == "__main__":
    root = tk.Tk()
    root.configure(background="#56B426")
    root.wm_title("ShiptScraper")
    app = HomePage(root)
    app.mainloop()

Note

Once we have triggered the existence of the Settings window, we can do a lot more of course, disable all buttons on the mainwindow for example. This way, we created our own version of grab_set() but even more flexibel.