Bugfix: When a menu has more than one toggle item, only one is updated
[rox-lib/lack.git] / python / rox / Menu.py
blob28391e683409b17c23bf7926db8fb992c15c0048
1 """The Menu widget provides an easy way to create menus that allow the user to
2 define keyboard shortcuts, and saves the shortcuts automatically. You only define
3 each Menu once, and attach it to windows as required.
5 Example:
7 from rox.Menu import Menu, set_save_name
9 set_save_name('Edit')
11 menu = Menu('main', [
12 ('/File', '', '<Branch>'),
13 ('/File/Save', 'save', ''),
14 ('/File/Open Parent', 'up', ''),
15 ('/File/Close', 'close', ''),
16 ('/File/', '', '<Separator>'),
17 ('/File/New', 'new', ''),
18 ('/Edit', '', '<Branch>'),
19 ('/Edit/Undo', 'undo', ''),
20 ('/Edit/Redo', 'redo', ''),
21 ('/Edit/', '', '<Separator>'),
22 ('/Edit/Search...', 'search', ''),
23 ('/Edit/Goto line...', 'goto', ''),
24 ('/Edit/', '', '<Separator>'),
25 ('/Edit/Process...', 'process', ''),
26 ('/Options', 'show_options', ''),
27 ('/Help', 'help', '<StockItem>', 'F1', g.STOCK_HELP),
30 There is also a new syntax, supported from 1.9.13, where you pass instances of MenuItem
31 instead of tuples to the Menu constructor. Be sure to require version 1.9.13 if
32 using this feature.
33 """
35 from __future__ import generators
37 import os
38 import rox
39 from rox import g
40 import choices, basedir
42 _save_name = None
43 def set_save_name(prog, leaf = 'menus', site = None):
44 """Set the directory/leafname (see choices) used to save the menu keys.
45 Call this before creating any menus.
46 If 'site' is given, the basedir module is used for saving bindings (the
47 new system). Otherwise, the deprecated choices module is used."""
48 global _save_name
49 _save_name = (site, prog, leaf)
51 class MenuItem:
52 """Base class for menu items. You should normally use one of the subclasses..."""
53 def __init__(self, label, callback_name, type = '', key = None, stock = None):
54 if label and label[0] == '/':
55 self.label = label[1:]
56 else:
57 self.label = label
58 self.fn = callback_name
59 self.type = type
60 self.key = key
61 self.stock = stock
63 def activate(self, caller):
64 getattr(caller, self.fn)()
66 class Action(MenuItem):
67 """A leaf menu item, possibly with a stock icon, which calls a method when clicked."""
68 def __init__(self, label, callback_name, key = None, stock = None, values = ()):
69 """object.callback(*values) is called when the item is activated."""
70 if stock:
71 MenuItem.__init__(self, label, callback_name, '<StockItem>', key, stock)
72 else:
73 MenuItem.__init__(self, label, callback_name, '', key)
74 self.values = values
76 def activate(self, caller):
77 getattr(caller, self.fn)(*self.values)
79 class ToggleItem(MenuItem):
80 """A menu item that has a check icon and toggles state each time it is activated."""
81 def __init__(self, label, property_name):
82 """property_name is a boolean property on the caller object. You can use
83 the built-in Python class property() if you want to perform calculations when
84 getting or setting the value."""
85 MenuItem.__init__(self, label, property_name, '<ToggleItem>')
86 self.updating = False
88 def update(self, menu, widget):
89 """Called when then menu is opened."""
90 self.updating = True
91 state = getattr(menu.caller, self.fn)
92 widget.set_active(state)
93 self.updating = False
95 def activate(self, caller):
96 if not self.updating:
97 setattr(caller, self.fn, not getattr(caller, self.fn))
99 class SubMenu(MenuItem):
100 """A branch menu item leading to a submenu."""
101 def __init__(self, label, submenu):
102 MenuItem.__init__(self, label, None, '<Branch>')
103 self.submenu = submenu
105 class Separator(MenuItem):
106 """A line dividing two parts of the menu."""
107 def __init__(self):
108 MenuItem.__init__(self, '', None, '<Separator>')
110 def _walk(items):
111 for x in items:
112 yield "/" + x.label, x
113 if isinstance(x, SubMenu):
114 for l, y in _walk(x.submenu):
115 yield "/" + x.label + l, y
117 class Menu:
118 """A popup menu. This wraps GtkMenu. It handles setting, loading and saving of
119 keyboard-shortcuts, applies translations, and has a simpler API."""
120 fns = None # List of MenuItem objects which can be activated
121 update_callbacks = None # List of functions to call just before popping up the menu
122 accel_group = None
123 menu = None # The actual GtkMenu
125 def __init__(self, name, items):
126 """names should be unique (eg, 'popup', 'main', etc).
127 items is a list of menu items:
128 [(name, callback_name, type, key), ...].
129 'name' is the item's path.
130 'callback_name' is the NAME of a method to call.
131 'type' is as for g.ItemFactory.
132 'key' is only used if no bindings are in Choices."""
133 if not _save_name:
134 raise Exception('Call rox.Menu.set_save_name() first!')
136 ag = g.AccelGroup()
137 self.accel_group = ag
138 factory = g.ItemFactory(g.Menu, '<%s>' % name, ag)
140 site, program, save_leaf = _save_name
141 if site:
142 accel_path = basedir.load_first_config(site, program, save_leaf)
143 else:
144 accel_path = choices.load(program, save_leaf)
146 out = []
147 self.fns = []
149 # Convert old-style list of tuples to new classes
150 if items and not isinstance(items[0], MenuItem):
151 items = [MenuItem(*t) for t in items]
153 items_with_update = []
154 for path, item in _walk(items):
155 if item.fn:
156 self.fns.append(item)
157 cb = self._activate
158 else:
159 cb = None
160 if item.stock:
161 out.append((path, item.key, cb, len(self.fns) - 1, item.type, item.stock))
162 else:
163 out.append((path, item.key, cb, len(self.fns) - 1, item.type))
164 if hasattr(item, 'update'):
165 items_with_update.append((path, item))
167 factory.create_items(out)
168 self.factory = factory
170 self.update_callbacks = []
171 for path, item in items_with_update:
172 widget = factory.get_widget(path)
173 fn = item.update
174 self.update_callbacks.append(lambda f = fn, w = widget: f(self, w))
176 if accel_path:
177 g.accel_map_load(accel_path)
179 self.caller = None # Caller of currently open menu
180 self.menu = factory.get_widget('<%s>' % name)
182 def keys_changed(*unused):
183 site, program, name = _save_name
184 if site:
185 d = basedir.save_config_path(site, program)
186 path = os.path.join(d, name)
187 else:
188 path = choices.save(program, name)
189 if path:
190 try:
191 g.accel_map_save(path)
192 except AttributeError:
193 print "Error saving keybindings to", path
194 # GtkAccelGroup has its own (unrelated) connect method,
195 # so the obvious approach doesn't work.
196 #ag.connect('accel_changed', keys_changed)
197 import gobject
198 gobject.GObject.connect(ag, 'accel_changed', keys_changed)
200 def attach(self, window, object):
201 """Keypresses on this window will be treated as menu shortcuts
202 for this object, calling 'object.<callback_name>' when used."""
203 def kev(w, k):
204 self.caller = object
205 return 0
206 window.connect('key-press-event', kev)
207 window.add_accel_group(self.accel_group)
209 def _position(self, menu):
210 x, y, mods = g.gdk.get_default_root_window().get_pointer()
211 width, height = menu.size_request()
212 return (x - width * 3 / 4, y - 16, True)
214 def popup(self, caller, event, position_fn = None):
215 """Display the menu. Call 'caller.<callback_name>' when an item is chosen.
216 For applets, position_fn should be my_applet.position_menu)."""
217 self.caller = caller
218 map(apply, self.update_callbacks) # Update toggles, etc
219 if event:
220 self.menu.popup(None, None, position_fn or self._position, event.button, event.time)
221 else:
222 self.menu.popup(None, None, position_fn or self._position, 0, 0)
224 def _activate(self, action, widget):
225 if self.caller:
226 try:
227 self.fns[action].activate(self.caller)
228 except:
229 rox.report_exception()
230 else:
231 raise Exception("No caller for menu!")