""" ################################################################################ An extended Frame that makes window menus and toolbars automatically. This is a general utility that can be used for any program's menu/tools. Use GuiMakerFrameMenu for embedded components (makes frame-based menus). Use GuiMakerWindowMenu for top-level windows (makes Tk8.0+ window menus). Menus built here are top-of-screen on a Mac, and top-of-window elsewhere. See the self-test code (and PyEdit) for an example layout tree format. Extended Jan/Apr-2017 (post PP4E) with: Menu acelerator keys Add menu accelerator keys (in addition to underline shortcut keys), via an optional and backward-compatible 4th item in menu command specs. Accelerators matter: underline shortcuts don't work on a Mac, and don't work on Windows when using a frame-based menu in embedded mode. Accelerators require both a menu setting, and an event binding on a widget or window. They use different control-key names on Mac and others by convention: '?-*-x' here means 'Control-Command-x' on Mac, and 'Alt-Control-x' elsewhere. This supports two control key replacements; menus may code others (e.g., Fn). For more docs and example usage, see this file's self-test below, and PyEdit. Note that accelerators take precedence over underline keys when they conflict, and accelerator keys have menu-global scope: they are not unique per pulldown. Automatic help menu tweaks Use a 'self.appname' string if present for the Unix auto-help menu's text. Caveat: auto-help menu isn't very flexible, and doesn't support accelerators. Underlines suppression in embedded mode Do not show Alt-key underlines in embedded mode. Underlines don't function in this mode, only for top-level window menus (and never on Mac OS X). Mac default-menus customization On Mac OS X, customize Tk's default "apple" (application) menu with the client's help (not Tk's); add an automatic Window menu that shows windows much like Dock; and catch and route Mac app-menu/Dock/shutdown Quits to the app, all per Tk's standard (yet arguably-convoluted) rules. For GuiMakerWindowMenu (top-level menu) clients, this is automatic at menu build time, and reuses the main/first window's help and quit callbacks. App-menu Help and Quit are app-global and shouldn't vary per window; WM quit (the upper-left red dot) and other menu items may still be per-window. For all other programs, a function is provided which should be called once per program with the program's root window's help and quit actions; the root menu will be inherited by all other windows, and any later guimaker client popups will reuse the program's help/quit. This function can also be used by programs that make no menu (but get one "for free" on the Mac), and additional function fixes menu-inheritance issues for menuless dialogs. Without the new code, menus wind up with automatic Tk-propaganda items, and a Quit in the app menu or Dock silently closes the program (odd default, that!). Mac also adds things like window controls, and an emojis/symbols selector in all Edit menus (useful in text-based apps, but largely pointless elsewhere). Toolbar spacers and Linux labels Spacers between button groups can be added, and made to expand/shrink proportionally with the window or not per an attribute on the subject object. Also, for Linux, use Labels instead of Buttons to avoid the too-wide layout of Tk buttons on Linux (only). See makeToolBar() below. ################################################################################ """ import sys from tkinter import * # widget classes from tkinter.messagebox import showinfo ############################################################################### # The main GUI border-builder class: a Frame with definable menu and toolbar ############################################################################### class GuiMaker(Frame): menuBar = [] # class defaults toolBar = [] # change per instance in subclasses helpButton = True # set these in start() if need self def __init__(self, parent=None): Frame.__init__(self, parent) # passed parent or implicit Tk() self.pack(expand=YES, fill=BOTH) # make this frame stretchable self.start() # for subclass: set menu/toolBar self.accbind = self.accBindWidget() # for subclass: if accelerators self.makeMenuBar() # done here: build menu bar self.makeToolBar() # done here: build toolbar self.makeWidgets() # for subclass: add middle part def makeMenuBar(self): """ -------------------------------------------------------- make menu bar at the top (see also Tk8.0 menus below); menubar uses expand=no, fill=x so same width on resize; -------------------------------------------------------- """ menubar = Frame(self, relief=RAISED, bd=2) menubar.pack(side=TOP, fill=X) # client-defined menus for (name, key, items) in self.menuBar: mbutton = Menubutton(menubar, text=name, underline=key) mbutton.pack(side=LEFT) pulldown = Menu(mbutton) self.addMenuItems(pulldown, items) mbutton.config(menu=pulldown) # automatic help button (no accelerator) if self.helpButton: Button(menubar, text = 'Help', cursor = 'gumby', # hmm relief = FLAT, command = self.onHelp).pack(side=RIGHT) def addMenuItems(self, menu, items): """ -------------------------------------------------------- scan nested items specs list, adding to menu; called recursively for any cascading submenus; Jan2017: add accelerators and their bindings; -------------------------------------------------------- """ for item in items: if type(item) == str: #--------------------------------------------------------------- # string: separator line (e.g., '----') #--------------------------------------------------------------- menu.add_separator() elif type(item) == list: #--------------------------------------------------------------- # list: disabled item numbers list (e.g., [0, 2, 3]) #--------------------------------------------------------------- for num in item: menu.entryconfig(num, state=DISABLED) elif type(item[2]) == list: #--------------------------------------------------------------- # sublist: menu cascade = (label, under, [items...]) #--------------------------------------------------------------- if not isinstance(self, GuiMakerWindowMenu): underarg = {} # not if embedded else: underarg = dict(underline = item[1]) # unders on Windows pullover = Menu(menu) # make and add submenu self.addMenuItems(pullover, item[2]) # recur for items menu.add_cascade(label = item[0], # add cascade menu = pullover, # no accelerator **underarg) # alt underline? else: #--------------------------------------------------------------- # callback: menu command = (label, under, cmd [, accel|None]) #--------------------------------------------------------------- if not isinstance(self, GuiMakerWindowMenu): # don't show alt-unders on Windows if embedded underarg = {} else: underarg = dict(underline = item[1]) # Mac ignores if len(item) == 3 or (len(item) == 4 and item[3] == None): # without accelerator (and b/w compat) accelarg = {} else: # with accelerator: per-platform keys if sys.platform.startswith('darwin'): # Mac hotkey, altkey = ('Command', 'Control') else: hotkey, altkey = ('Control', 'Alt') # Others accstr = item[3].replace('*', hotkey).replace('?', altkey) # reformat for Windows; Mac accepts and converts to icons disstr = accstr.replace('-', '+').replace('Control', 'Ctrl') accelarg = dict(accelerator = disstr) # make menu entry with possible shortcuts shortcutargs = underarg shortcutargs.update(accelarg) menu.add_command(label = item[0], # add command command = item[2], # action=callable **shortcutargs) # under? accel? # bind accelerator using tk event syntax if accelarg: def callback(event, command=item[2]): # item[2] saves loop's current value (else=last?); # returns 'break' to disable standard tk bindings, # else cmd/ctrl-v may wind up pasting text twice! command() return 'break' tkspec = '<' + accstr +'>' self.accbind.bind(tkspec, callback) def makeToolBar(self): """ -------------------------------------------------------- make button bar at bottom of window, if any; expand=no, fill=x (defaults_ so same width on resize; this could support images too, per Chapter 9: would require prebuilt gifs or a PIL install for thumbnails; Mar2017: add spacers if item is a str, with fixed or expanding layout, system default or set font, where '>...' packs on the right, '....' packs on the left; Apr2017: use narrower Labels, not Buttons, on Linux; -------------------------------------------------------- """ if self.toolBar: toolbar = Frame(self, cursor='hand2', relief=SUNKEN, bd=2) toolbar.pack(side=BOTTOM, fill=X) for item in self.toolBar: if isinstance(item, str): # spacer side = RIGHT if item.startswith('>') else LEFT if getattr(self, 'toolbarFixedLayout', False): lab = Label(toolbar, text=' ') lab.pack(side=side, expand=NO) else: lab = Label(toolbar, text='') lab.pack(side=side, expand=YES) else: # button with callback (name, action, where) = item if sys.platform.startswith('linux'): but = Label(toolbar, text=name, bd=1, relief=RAISED) but.bind('', lambda evt, act=action: act()) else: but = Button(toolbar, text=name, command=action) but.pack(where) if getattr(self, 'toolbarFont', False): but.config(font=self.toolbarFont) #---------------------------------- # subclass protocol methods follow #---------------------------------- def start(self): """ call 1: setup menu/toolbar structure; override me in subclass to use self; """ pass def accBindWidget(self): """ call 2: return bind widget for accelerators; override me in subclass if accelerators used; """ return None def makeWidgets(self): """ call 3: after menu/toolbar built here, make 'middle' part last, so menu/toolbar is always on top/bottom of window, and clipped last on all window resizes; override this default, pack middle part on any side; for grids: grid middle part in a dummy packed frame; """ name = Label(self, width=40, height=10, relief=SUNKEN, bg='white', text = self.__class__.__name__, cursor = 'crosshair') name.pack(expand=YES, fill=BOTH, side=TOP) def onHelp(self): "default: override me in subclass" showinfo('Help', 'Sorry, no help for ' + self.__class__.__name__) def onAbout(self): "default: override me in subclass" self.onHelp() # default to Help if no About def onQuit(self): "default: override me in subclass" self.quit() # default to Tk shutdow or class's Quit ############################################################################### # Customize for Tk 8.0+ main window menu bar, instead of a frame ############################################################################### # Use this variant for embedded component menus (Frames). # On Mac, such windows should not change the main menu bar. GuiMakerFrameMenu = GuiMaker class GuiMakerWindowMenu(GuiMaker): """ -------------------------------------------------------------- Use this variant for top-level window menus in Tk 8.0+: at top of screen on Mac, at top of windows on Windows+Linux. Called only for top-level windows, not embedded Frame menus. Jan2017: On Mac OS X, customize apple (app) and help, add Windows, catch app and Dock Quit. Unlike others, app and Window require 'name' keyword argument; don't use 'name' for help, else source gets auto 'Python'/appname entry too. In all windows built, Help and Quit are routed to those of the first, which is assumed to be the app Tk root. This model assumes these are app-wide, not window-specific. We need to customise Mac's default menus here, not by a later call to fixAppleMenuBar ahead: menus appear as laid out if they are rebuilt, and not inherited from the root window. Also route MAC's standard app menu Quit, also called for Quit in Dock and system shutdown. This is app-wide quit and differs from the WM close button (the upper-left red circle) which may still be window-specific. Not catching it exits the app sliently losing any changes in the process. The Quit action is registered just once for first/root window. -------------------------------------------------------------- """ # class attrs, localized to this class's name __firstWindow = True # e.g., first PyEdit Tk -or- embedding app's root __appName = None # use root's callbacks for all app windows __appHelp = None # per-window actions in non-default menus __appAbout = None # about = help by default, per GuiMaker super __appQuit = None # quit = per GuiMaker super or app call, by default __runningOnMac = sys.platform.startswith('darwin') __runningOnWindows = sys.platform.startswith('win') __runningOnLinux = sys.platform.startswith('linux') def makeMenuBar(self): """ make menu bar at top of window (Windows, Linux) or display (Mac); on Mac, also customize app-wide defaults redrawn for each client window, and and set quit handler run on app-menu Quit and Dock; """ if self.__firstWindow: # # Popups (non-Tks) should not be the firstWindow here; # if one is, it was opened by another program that did # not call fixAppleMenuBar; see that for more details. # if not isinstance(self.master, Tk): print("Warning: using a popup window's quit and help.") print('To avoid this, call guimaker.fixAppleMenuBar().') GuiMakerWindowMenu.__appName = getattr(self, 'appname', '') GuiMakerWindowMenu.__appHelp = self.onHelp GuiMakerWindowMenu.__appAbout = self.onAbout GuiMakerWindowMenu.__appQuit = self.onQuit window = self.master assert isinstance(window, (Tk, Toplevel)) menubar = Menu(window) # Mac: customize standard app menu if self.__runningOnMac: appmenu = Menu(menubar, name='apple') if self.__appName: menutext = 'About ' + self.__appName else: menutext = 'About' appmenu.add_command(label=menutext, command=self.__appAbout) menubar.add_cascade(menu=appmenu) # All: client-defined menus, via data structure (per-window) for (name, key, items) in self.menuBar: pulldown = Menu(menubar) self.addMenuItems(pulldown, items) menubar.add_cascade(menu=pulldown, label=name, underline=key) # Mac: add automatic windows (dock-ish) menu if self.__runningOnMac: winmenu = Menu(menubar, name='window') menubar.add_cascade(menu=winmenu, label='Window') # Automatic help menu last=rightmost (no accelerator, always on Mac) if self.helpButton or self.__runningOnMac: if self.__runningOnWindows: # Windows: just a button on menu bar menubar.add_command(label='Help', command=self.__appHelp) else: # Linux+Mac: need a real menu pulldown, Mac augments it assert self.__runningOnMac or self.__runningOnLinux if self.__appName: menutext = self.__appName + ' Help' else: menutext = 'About' helpmenu = Menu(menubar) # omit name helpmenu.add_command(label=menutext, command=self.__appHelp) menubar.add_cascade(menu=helpmenu, label='Help') # attach to window last, so app/etc menus work on Mac window.config(menu=menubar) # Mac: catch std app-menu/Dock/shutdown Quit: this != WM close button # registers this once, uses .tk to get to _tkinter from Toplevel or Tk if self.__runningOnMac and self.__firstWindow: window.tk.createcommand('tk::mac::Quit', self.__appQuit) GuiMakerWindowMenu.__firstWindow = False # for the next instance @staticmethod def setAppWideInfo(appname, helpaction, aboutaction, quitaction): """ provide access to unmangled names from outside this class; a classmethod would work here too (self=class object); """ GuiMakerWindowMenu.__firstWindow = False GuiMakerWindowMenu.__appName = appname GuiMakerWindowMenu.__appHelp = helpaction GuiMakerWindowMenu.__appAbout = aboutaction GuiMakerWindowMenu.__appQuit = quitaction @staticmethod def getAppWideInfo(): """ provide access to unmangled names from outside this class; a classmethod would work here too (self=class object); """ return (GuiMakerWindowMenu.__appName, GuiMakerWindowMenu.__appHelp, GuiMakerWindowMenu.__appAbout, GuiMakerWindowMenu.__appQuit) ############################################################################### # For non-GuiMakerWindowMenu clients: customize default menus on Mac OS X ############################################################################### def fixAppleMenuBar(window, # a Tk or Toplevel window appname, # text added to menu labels helpaction=None, # Help menu callback, no args aboutaction=None, # About calback, default=Help quitaction=None): # Quit callback no args, else no-op """ ----------------------------------------------------------------------- Usage: this should be called on Mac OS for all programs that are not GuiMakerWindowMenu clients themselves, but create GuiMakerWindowMenu client popup windows: it picks up and saves the app's quit and help to apply to popups. For other programs, this call is optional but useful for minimal menu and Dock config. This is a no-op on Windows an Linux. Details: for Mac/Tk programs that are not GuiMakerWindowMenu clients, this function customizes the default Mac menus that always show up at top of screen even if the program builds no real menu. Call this once per program, with the main window's app-wide help/quit actions; the customized menu with these actions will be inherited by other windows in the program that do not build a per-window menu of their own. Without this, Mac/Tk programs wind up with Tk-propaganda help and demos, and a Quit in the app menu or Dock silently closes the entire program - changes or not. On Windows and Linux calling this is a no-op because no menubar appears unless one is built explicitly (unlike Mac). Note that program-defined menus can vary per window (e.g., "Cut" may apply to the current window's text) if a new menu is built for new windows (that's why GuiMakerWindowMenu repeats some code here), and WM quit can still vary per window. By contrast, the settings made here apply to the entire app/program, not individual windows, and remain in force for all windows' menus. In terms of use cases: - Programs that mix in GuiMakerWindowMenu for top-toplevel menus: do *not* call this, as menus are customized in the superclass. - Program that embed GuiMakerFrameMenu windows: call this once for the app's main window, not for windows with embedded menus. - Programs that build no program-specific menus of their own: call this once for the program's main window. Subtlety: this call is basically *required* of non-guimaker client programs run on Mac that make GuiMakerWindowMenu-client popups. For example, programs that embed PyEdit as a library (e.g., PyMailGUI) may create both frame-based menus and standalone popup windows. For the latter, this saves this app's help/quit info, to be applied to menus built for later PyEdit popups in GuiMakerWindowMenu. In this use case, the 'first' window is the enclosing app, not a PyEdit Tk; its app-wide help and quit will be used in the PyEdit popups' menus too. Mac's always-present menu paradigm differs markedly from Windows, and requires extra steps. Modal dialogs may also disable menu actions, and non-modal dialogs can either redraw menus minimally or allow them to remain active: see fixAppleMenuBarChild() ahead. ----------------------------------------------------------------------- """ # defaults helpaction = helpaction or (lambda: showinfo(appname, 'No help available')) aboutaction = aboutaction or helpaction quitaction = quitaction or (lambda: None) # save this app's info for use in any GuiMaker-based popups it creates GuiMakerWindowMenu.setAppWideInfo(appname, helpaction, aboutaction, quitaction) if sys.platform.startswith('darwin'): # for Mac only menubar = Menu(window) # for this window # customize standard app menu on Mac menutext = 'About ' + appname appmenu = Menu(menubar, name='apple') appmenu.add_command(label=menutext, command=aboutaction) menubar.add_cascade(menu=appmenu) # add automatic windows (dock-ish) menu on Mac winmenu = Menu(menubar, name='window') menubar.add_cascade(menu=winmenu, label='Window') # automatic help menu last=rightmost (no accelerator) menutext = appname + ' Help' helpmenu = Menu(menubar) # omit name helpmenu.add_command(label=menutext, command=helpaction) menubar.add_cascade(menu=helpmenu, label='Help') # attach to window last, so app/etc menus work window.config(menu=menubar) # catch std app-menu/Dock/shutdown Quit: this != WM close button # registers this once, uses .tk to get to _tkinter from any window.tk.createcommand('tk::mac::Quit', quitaction) def fixAppleMenuBarChild(window): # a Tk or Toplevel window """ ----------------------------------------------------------------------- Usage: on Mac OS X, 1) Programs that are NOT a client of the GuiMakerWindowMenu class can call this to build default menus for the app on child windows that have no real menu, but no longer inherit one from the root due to other menu-ful windows. 2) Programs that ARE GuiMakerWindowMenu clients may need to call this too, to build a minimal explicit menu for non-modal dialogs without real menus of their own. Details: but wait - the Mac Tk menu story gets more convoluted! According to these: http://wiki.tcl.tk/12987#pagetoc6efbd677 http://www.tcl.tk/software/mac/macFAQ.tml?sc_format=wider#Q5.3 And as strongly suggested by this: http://www.tcl.tk/man/tcl8.6/TkCmd/menu.htm#M22, Toplevel windows that don't build an explicit menu are supposed to inherit the root Tk window's menu automatically. At least in ActiveState's Tk 8.5.18 on Mac OS X 10.11, they do - BUT ONLY UNTIL another Toplevel makes a menu of its own; at which point the Toplevels without an explicit menu pick up the _other_ Toplevel's menu, and may wind up displaying an empty menu bar if they get focus when the other Toplevel is destroyed. This cropped up for PyEdit popups in PyMailGUI: their PyEdit menus trash the inherited menu of PyMailGUI view windows. Before this fix, this also happened for menuless nonmodal PyEdit dialogs like Change, Grep, and Help that stay up and may be clicked any time: opening and closing another explicit-menu PyEdit window creates empty menu bars for dialog windows if they regain focus first after the close. This is probably a bug in AS TK 8.5.16 (or Mac 10.11, or tkinter?), but as a workaround, this function builds an explict menu on Toplevels which is the same as that formerly built for their parent. This fixes the PyMailGUI+PyEdit use case; PyEdit standalone edit windows don't need to care (each has its own menu), but all PyEdit nonmodal dialogs do; and in programs without real menus like frigcal and mergeall, child Toplevels do inherit as they should. This workaround may or may not be required on Tk 8.6 available in Homebrew Python; TBD (a workaround is less painful than an install). ----------------------------------------------------------------------- """ # use the app root's info, from former window or call # ignore quitaction: assume the app-wide tk::mac::Quit already registered appname, helpaction, aboutaction, quitaction = ( GuiMakerWindowMenu.getAppWideInfo()) if sys.platform.startswith('darwin'): # for Mac only menubar = Menu(window) # for this window # customize standard app menu on Mac menutext = 'About ' + appname appmenu = Menu(menubar, name='apple') appmenu.add_command(label=menutext, command=aboutaction) menubar.add_cascade(menu=appmenu) # add automatic windows (dock-ish) menu on Mac winmenu = Menu(menubar, name='window') menubar.add_cascade(menu=winmenu, label='Window') # automatic help menu last=rightmost (no accelerator) menutext = appname + ' Help' helpmenu = Menu(menubar) # omit name helpmenu.add_command(label=menutext, command=helpaction) menubar.add_cascade(menu=helpmenu, label='Help') # attach to window last, so app/etc menus work window.config(menu=menubar) ############################################################################### # Self-test when file run standalone: 'python guimaker.py' ############################################################################### if __name__ == '__main__': import os sys.path.append(os.path.join('..', '..', '..')) # testing this copy from guimixin import GuiMixin # mix in help method menuBar = [ ('File', 0, [('Open', 0, (lambda: print('open')), '*-o'), # lambda defers code ('Save', 0, (lambda: print('save')), 'F1'), # function keys too ('Quit', 0, sys.exit, '?-q')] # use sys: no self ), ('Edit', 0, [('Cut', 0, (lambda: print('cut')), '?-*-c'), ('Copy', 2, (lambda: print('copy')), None), # no accel (or omit) '----', ('Paste', 0, (lambda: print('paste')), '*-v'), # replace Text dflt ('Spam', 0, (lambda: print('spam')), '?-s')] ) ] toolBar = [('Quit', sys.exit, {'side': LEFT})] class TestMixin: appname = 'TestMixin' # optional label for auto help menu def start(self): self.menuBar = menuBar self.toolBar = toolBar # set menu/toolbar here if need self def accBindWidget(self): self.middle = Text(self) return self.middle # need a real widget type for bind def makeWidgets(self): self.middle.insert(END, self.__class__.__name__) self.middle.pack() class TestAppFrameMenu(TestMixin, GuiMixin, GuiMakerFrameMenu): onHelp = GuiMixin.help class TestAppWindowMenu(TestMixin, GuiMixin, GuiMakerWindowMenu): onHelp = GuiMixin.help class TestAppWindowMenuPolite(TestMixin, GuiMakerWindowMenu): pass # guimaker help, not guimixin root = Tk() TestAppWindowMenuPolite(root) TestAppFrameMenu(Toplevel()) TestAppWindowMenu(Toplevel()) other = Toplevel() root.mainloop()