Search code examples
python-3.xmacostkinter

Catch macos ⌘Q in python tkinter application


Within a tkinter application, I am catching several events to gracefully shutdown some threads before the main thread terminates.

This is all working swiftly as long as I use a bound key combination or the window control, the cross in the red circle.

On macos the application automatically gets a 'python' menu with a close function bound to key combination ⌘Q. This event is not handled properly. It seems to kill the main thread but other threads are not closed properly.

Following bindings are used to catch all closing events:

self.root.bind('<Control-x>', self.exitapp)
self.root.protocol("WM_DELETE_WINDOW", self.exitapp)

atexit.register(self.catch_atexit)

Recently found that the left and right keys are repesented as Meta_L and Meta_R but cannot be combined with a second key, i.e. '<Meta_L-q>'.

Can anyone explain howto catch ⌘Q?

Please find code example below:

#!/usr/bin/env python3
import sys
from tkinter import *
from tkinter import ttk

import threading
import time

import atexit


class subthread():
    def __init__(self):
        self.thr = None
        self.command = ''
        self.proof = ""

    def start(self):
        if not self.thr or not self.thr.is_alive():
            self.command = 'run'
            self.thr = threading.Thread(target=self.loop)
            self.thr.start()
        else:
            print('thread already running')

    def stop(self):
        self.command = 'stop'
        if self.thr and self.thr.is_alive():
            print('stopping thread')
        else:
            print('thread not running')

    def running(self):
        return True if self.thr and self.thr.is_alive() else False

    def get_proof(self):
        return self.proof

    def loop(self):
        while self.command == 'run':
            time.sleep(0.5)
            print('+', end='')
            self.proof += '+'
            if len(self.proof) > 30:
                self.proof = ""

    def __del__(self):
        print('del instance subthread')
        self.command = 'stop'
        if self.thr and self.thr.is_alive():
            self.thr.join(2)


class app():
    def __init__(self, rootframe):
        self.root = rootframe
        self.gui = ttk.Frame(self.root)
        self.gui.pack(fill=BOTH)
        row = 0
        self.checkvar = IntVar()
        self.checkvar.trace('w', self.threadchange)
        ttk.Label(self.gui, text="Use checkbox to start and stop thread").grid(row=row, column=0, columnspan=2)
        ttk.Checkbutton(self.gui, text='thread', variable=self.checkvar).grid(row=1, column=0)
        self.threadstatus = StringVar()
        self.threadstatus.set('not running')
        row += 1
        ttk.Label(self.gui, textvariable=self.threadstatus).grid(row=row, column=1)
        row += 1
        self.alivestring = StringVar()
        ttk.Entry(self.gui, textvariable=self.alivestring).grid(row=row, column=0, padx=10, sticky="ew",
                                                                      columnspan=3)
        row += 1
        ttk.Separator(self.gui, orient="horizontal").grid(row=row, column=0, padx=10, sticky="ew",
                                                                      columnspan=3)
        row += 1
        ttk.Label(self.gui, text="- Available options to close application: [ctrl]-x,"
                                 " window-control-red, [CMD]-q").grid(row=row, column=0, padx=10, columnspan=3)
        row += 1
        ttk.Label(self.gui, text="1. Try all three without thread running").grid(row=row, column=0,
                                                                                 columnspan=3, sticky='w')

        row += 1
        ttk.Label(self.gui, text="2. Retry all three after first starting the thread").grid(row=row, column=0,
                                                                                            columnspan=3, sticky='w')

        row += 1
        ttk.Label(self.gui, text="3. Experience that only [CMD]-q fails").grid(row=row, column=0,
                                                                               columnspan=3, sticky='w')


        self.subt = subthread()

        self.root.bind('<Control-x>', self.exitapp1)
        self.root.protocol("WM_DELETE_WINDOW", self.exitapp2)

        atexit.register(self.catch_atexit)

        self.root.after(500, self.updategui)

    def threadchange(self, a, b, c):
        """ checkbox change handler """
        try:
            if self.checkvar.get() == 1:
                self.subt.start()
            else:
                self.subt.stop()
        except Exception as ex:
            print('failed to control subt', str(ex))

    def updategui(self):
        """ retriggering timer handler to update status label gui """
        try:
            if self.subt.running():
                self.threadstatus.set("thread is running")
            else:
                self.threadstatus.set("thread not running")
            self.alivestring.set(self.subt.get_proof())
        except:
            pass
        else:
            self.root.after(500, self.updategui)

    def __del__(self):
        print('app del called')

    def exitapp1(self, a):
        print('exitapp1 called')
        self.subt.stop()
        sys.exit(0)

    def exitapp2(self):
        print('exitapp2 called')
        self.subt.stop()
        sys.exit(0)

    def catch_atexit(self):
        print('exitapp called')
        self.subt.stop()
        self.subt = None
        sys.exit(0)


if __name__ == '__main__':
    root = Tk()
    dut = app(rootframe=root)

    root.mainloop()

    print('main exiting')
    sys.exit(0)


Solution

  • You can catch ⌘Q with <Command-q>:

    ...
    def action(event):
      print("bind!")
    
    root.bind_all("<Command-q>", action)
    ...
    

    This worked for me on macOS High Sierra.