Reputation: 111
I have a Tkinter GUI with a main menubar, using Tkinter's Menu
widget. I want to execute code prior to posting a submenu (another Menu item cascaded from it via .add_cascade()
), so that I can dynamically change its contents before it's shown. I have this working using Menu
's postcommand argument, but I noticed a huge inefficiency using that; the postcommand callback for all submenus are called when clicking on any one submenu, not just the specific submenu that was created to have the callback. Even clicking on the menu bar where there are no menu items also executes all callbacks, even though no submenus are created.
Is this expected behavior from the Menu module and its postcommand argument? I don't understand why this still happens after creating separate Menu instances for the dropdowns.
I've tried hooking into Tk.Menu's native methods, but none of them are called when simply clicking on the menubar items to bring up a cascaded Menu. And even though .add_cascade() accepts a 'command' argument, it only calls the callable provided by that if .add_cascade()'s 'menu' argument is not included or if it's a lambda expression (both of which result in no submenu being displayed when you click on the item). (You can see this using the test()
function, below.)
Here's a simple app showing this behavior:
import Tkinter as Tk
import time
def test(): print 'test'
class firstMenu( Tk.Menu ):
def __init__( self, parent, tearoff=False ):
Tk.Menu.__init__( self, parent, tearoff=tearoff, postcommand=self.repopulate )
def repopulate( self ):
print 'repopulating firstMenu'
time.sleep( 2 ) # Represents some thinking/processing
# Clear all current population
self.delete( 0, 'last' )
# Add the new menu items
self.add_command( label='Option 1.1' )
self.add_command( label='Option 1.2' )
class secondMenu( Tk.Menu ):
def __init__( self, parent, tearoff=False ):
Tk.Menu.__init__( self, parent, tearoff=tearoff, postcommand=self.repopulate )
def repopulate( self ):
print 'repopulating secondMenu'
time.sleep( 2 ) # Represents some thinking/processing
# Clear all current population
self.delete( 0, 'last' )
# Add the new menu items
self.add_command( label='Option 2.1' )
self.add_command( label='Option 2.2' )
class Gui( object ):
def __init__( self ): # Create the TopLevel window
root = Tk.Tk()
root.withdraw() # Keep the GUI minimized until it is fully generated
root.title( 'Menu Test' )
# Create the GUI's main program menus
menubar = Tk.Menu( root )
menubar.add_cascade( label='File', menu=firstMenu( menubar ), command=test )
menubar.add_cascade( label='Settings', menu=secondMenu( menubar ) )
root.config( menu=menubar )
root.deiconify() # Brings the GUI to the foreground now that rendering is complete
# Start the GUI's mainloop
root.mainloop()
root.quit()
if __name__ == '__main__': Gui()
If you click on anywhere on the menubar, BOTH postcommand callbacks are called. I need (and would expect) only one of them to be called when you click on the respective Menu item.
I'm not sure if it's relevant, but I also use the same menu items as context-menus over another widget. So their .post() method also needs to be able to trigger the same callback before the menu is displayed.
Thanks in advance if you have any insight.
Upvotes: 3
Views: 988
Reputation: 111
This was a really tricky problem, but I finally found a solution. After a lot of searching, numerous failed experiments, and more searching, I came across the virtual event <<MenuSelect>>
and this pivotal line of code: print tk.call(event.widget, "index", "active")
, pointed out by Michael O' Donnell, here.
The first weird part about trying to use this, is that event.widget
isn't an instance of a widget object in this case, it's a tcl/tk path name string, e.g. '.#37759048L'. (This seems to be a bug in Tkinter, as even other virtual events I've tested -TreeviewSelect and NotebookTabChanged- include actual widget instances, as expected.) Regardless, the tcl/tk string can be used by the print tk.call(event.widget, "index", "active")
command; that returns the index of the currently active menu item, which is huge.
The second issue that comes up with using the MenuSelect event is that it's called multiple times when traversing the menus normally. Clicking on a menu item calls it twice, and moving the mouse to a neighboring menu item, or moving the mouse to a submenu and then back to the main menu item, will also call it twice. Leaving the menu can as well. But this can be cleaned up nicely by adding a flag to the Menu classes and a little logic to the event handler. Here's the full solution:
import Tkinter as Tk
import time
class firstMenu( Tk.Menu ):
def __init__( self, parent, tearoff=False ):
Tk.Menu.__init__( self, parent, tearoff=tearoff )
self.optionNum = 0 # Increments each time the menu is show, so we can see it update
self.open = False
def repopulate( self ):
print 'repopulating firstMenu'
# Clear all current population
self.delete( 0, 'last' )
# Add the new menu items
self.add_command( label='Option 1.' + str(self.optionNum+1) )
self.add_command( label='Option 1.' + str(self.optionNum+2) )
self.optionNum += 2
class secondMenu( Tk.Menu ):
def __init__( self, parent, tearoff=False ):
Tk.Menu.__init__( self, parent, tearoff=tearoff )
self.optionNum = 0 # Increments each time the menu is show, so we can see it update
self.open = False
def repopulate( self ):
print 'repopulating secondMenu'
# Clear all current population
self.delete( 0, 'last' )
# Add the new menu items
self.add_command( label='Option 2.' + str(self.optionNum+1) )
self.add_command( label='Option 2.' + str(self.optionNum+2) )
self.optionNum += 2
class Gui( object ):
def __init__( self ): # Create the TopLevel window
self.root = Tk.Tk()
self.root.withdraw() # Keep the GUI minimized until it is fully generated
self.root.title( 'Menu Tests' )
# Create the GUI's main program menus
self.menubar = Tk.Menu( self.root )
self.menubar.add_cascade( label='File', menu=firstMenu( self.menubar ) )
self.menubar.add_cascade( label='Settings', menu=secondMenu( self.menubar ) )
self.root.config( menu=self.menubar )
self.root.deiconify() # Brings the GUI to the foreground now that rendering is complete
# Add an event handler for activation of the main menus
self.menubar.bind( "<<MenuSelect>>", self.updateMainMenuOptions )
# Start the GUI's mainloop
self.root.mainloop()
self.root.quit()
def updateMainMenuOptions( self, event ):
activeMenuIndex = self.root.call( event.widget, "index", "active" ) # event.widget is a path string, not a widget instance
if isinstance( activeMenuIndex, int ):
activeMenu = self.menubar.winfo_children()[activeMenuIndex]
if not activeMenu.open:
# Repopulate the menu's contents
activeMenu.repopulate()
activeMenu.open = True
else: # The active menu index is 'none'; all menus are closed
for menuWidget in self.menubar.winfo_children():
menuWidget.open = False
if __name__ == '__main__': Gui()
The end result is that each menu's code to generate its contents, via .repopulate()
, is only called once, and only if that particular menu is actually going to be shown. The method isn't called again until the whole main menu is left and re-opened. Works when navigating via keyboard too.
Upvotes: 2