Vladimir Zolotykh
Vladimir Zolotykh

Reputation: 529

nested tkinter context menu

There are a lot of examples how to make a tkinter context menu. I have seen no one with the nested menus (.add_cascade were used). I attempted to make one and it exposed a problem (or peculiarity ) never mentioned. That's how .tk_popup or .post created menu window behaves when the sub menu (nested) item is invoked.

It is a complete example of a popup context menu (Python 3.9.2, Debian bullseye) which works fine until the "Insert" (sub menu) is gonna be selected. The example has two files: popup.py, canvas.py. I'm using classes in popup.py in other script.

class Popup(tk.Menu):
    def __init__(self, *args, **kwargs):
        super(Popup, self).__init__(*args, **kwargs)
        self.bind('<FocusOut>', self.on_focus_out)
        self.bind('<Escape>', self.on_focus_out)

    def post(self, *args, **kwargs):
        super(Popup, self).post(*args, **kwargs)
        self.focus_set()

    def on_focus_out(self, event=None):
        self.unpost()


class PopupMixin:
    def __init__(self, *args, **kwargs):
        self.tk_focusFollowsMouse()
        self.bind('<3>', self.on_click3)
        self.popup = None

    def on_click3(self, event):
        try:
            self.popup.post(event.x_root, event.y_root)
        finally:
            self.popup.grab_release()

canvas.py

class SPopupMixin(PopupMixin):  # S[uper]PopupMixin
    pass


class Canvas(tk.Canvas, SPopupMixin):
    def __init__(self, *args, **kwargs):
        super(Canvas, self).__init__(*args, **kwargs)
        super(SPopupMixin, self).__init__(*args, **kwargs)
        self.popup = Canvas.make_top_menu(self)

    @staticmethod
    def make_top_menu(parent):
        popup = Popup(parent, tearoff=0)
        popup.highlightthickness = 1
        popup.add_command(label='Loop', command=lambda: print('Loop'))
        popup.add_command(label='Single', command=lambda: print('Single'))
        popup.add_cascade(label='Insert', menu=Canvas.make_sub_menu(popup))
        return popup

    @staticmethod
    def make_sub_menu(parent):
        sub = Popup(parent, tearoff=0)
        sub.add_command(label='Glider', command=lambda: print('Glider'))
        return sub


def main():
    canv = Canvas(takefocus=1)
    canv.pack()
    canv.mainloop()


if __name__ == '__main__':
    main()

The functionality I'd like to keep: Escape key pressed and moving the mouse outside the popup window (focus is lost) closes the popup menu so the Menu window never remains open if a user hasn't chosen an item.

When mouse button <3> is clicked on the Canvas the menu is shown. It is easy to invoke "Loop" or "Single"(see the example) (the popup remains visible until either is clicked). Invoking "Insert" is tricky. One way is to "click-and-drag" until sub menu "Glider" appears. Then release the mouse button over "Glider" and "Glider" is activated. This is inconvenient and differs from the way the 1st level items are selected. I'd like to understand the reason for that. Just clicking "Insert" gives nothing. The sub menu "Glider" disappears too soon to select it.

Let's call "clicking an item" the "natural" way of invoking the menu item. In the example we see "click-and-drag" way.

Is it possible to get the "natural way" of selecting the nested item ("Glider") in the example? In the week time I have not found how.

I started with .tk_popup. Have got better results with .post

Using Python 3.9.2, Debian bullseye, GNOME 3.38.5

UPDATE

answering Bryan Oakley question:

Sequence of events when "Glider" is selected the "click-and-drag" way:

focus_in id(event.widget) = 139798253649728
Glider
focus_out id(event.widget) = 139798253649728

Sequence of events when I attempted to select "Glider" by clicking "Insert":

focus_in id(event.widget) = 139760112942912
focus_out id(event.widget) = 139760112942912
focus_in id(event.widget) = 139760097572752 
focus_out id(event.widget) = 139760097572752

"Glider" sub menu just blinks for a split of a second

When

# self.bind('<FocusOut>', self.on_focus_out)

menu works the "normal way" with exception that the popup window remains open (visible) until any choice is made, even when I switch apps (Alt-Tab).

UPDATE 10/25

When I try to invoke "Glider" menu choice in the normal way (click "Insert" , hover mouse over "Glider" and click it) one mouse click is lost after that. [for now "normal" way of selecting "Glider" doesn't work, the "Glider" sub menu disappears too soon.]

UPDATE 10/26

This is the next approximation to what I'd like to have.

$ git diff
diff --git a/popup.py b/popup.py
index 03e9a77..4630e7f 100644
--- a/popup.py
+++ b/popup.py
@@ -6,8 +6,11 @@ import tkinter as tk
 class Popup(tk.Menu):
     def __init__(self, *args, **kwargs):
         super(Popup, self).__init__(*args, **kwargs)
-        self.bind('<FocusOut>', self.on_focus_out)
         self.bind('<Escape>', self.on_focus_out)
+        self.bind('<Enter>', self.on_enter)
+
+    def on_enter(self, event):
+        self.focus_set()
 
     def post(self, *args, **kwargs):
         super(Popup, self).post(*args, **kwargs)

<FocusOut> handler is removed , <Enter> is added instead. The "Glider" nested menu item is selected the normal way (clicking "Insert" , moving pointer, clicking "Glider"). Context menu can be closed any time by pressing the <Escape> key.

Upvotes: 0

Views: 302

Answers (1)

Vladimir Zolotykh
Vladimir Zolotykh

Reputation: 529

The following Popup class satisfies the set "requirements":

Escape key pressed and/or moving the mouse outside the popup window (focus is lost) closes the popup menu so the Menu window never remains open if a user hasn't chosen an item.

class Popup(tk.Menu):
    """Two types of nested menu item invoking. 'click&drag' - click
    and hold the menu item, move pointer to sub-menu item. Release the
    button when it is over the sub-menu item. 'click&move' - click the
    menu item (sub-menu appears), click the sub-menu item.

    Controlled by *menumode* keyword arguemnt. 0 - click&move, 1 -
    click&drag"""

    menumode_default = 0

    def __init__(self, *args, menumode=None, **kwargs):
        super(Popup, self).__init__(*args, **kwargs)
        menumode = self.menumode_default if menumode is None else menumode
        if menumode:
            self.bind('<FocusOut>', self.on_focus_out)
        else:
            self.bind('<Enter>', self.on_enter)
        self.bind('<Escape>', self.on_focus_out)

    def on_enter(self, event):
        self.focus_set()

    def post(self, *args, **kwargs):
        super(Popup, self).post(*args, **kwargs)
        self.focus_set()

    def on_focus_out(self, event=None):
        self.unpost()

It support two modes: click&drag, click&move which described in the class doc string. It is tested for Python 3.9.2, Debian bullseye. I haven't found the easy way to keep only one method (on_enter or on_focus_out) which has bound in __init__

Upvotes: 1

Related Questions