Improve button mapping, spring-loaded modes
[mypaint:maxy-experimental.git] / gui / buttonmap.py
1 """Button press mapping.
2 """
3
4 import gtk
5 from gtk import gdk
6 import gobject
7 import pango
8
9 from gettext import gettext as _
10
11 from lib.helpers import escape
12
13
14 def button_press_name(button, mods):
15     """Converts button number & modifier mask to a prefs-storable string.
16
17     Analogous to `gtk.accelerator_name()`.  Buttonpress names look similar to
18     GDK accelerator names, for example ``<Control><Shift>Button2`` or
19     ``<Primary><Alt>Button4`` for newer versions of GTK.  If the button is
20     equal to zero (see `button_press_parse()`), `None` is returned.
21
22     """
23     button = int(button)
24     mods = int(mods)
25     if button <= 0:
26         return None
27     modif_name = gtk.accelerator_name(0, mods)
28     return modif_name + "Button%d" % (button,)
29
30
31 def button_press_parse(name):
32     """Converts button press names to a button number & modifier mask.
33
34     Analogous to `gtk.accelerator_parse()`. This function parses the strings
35     created by `button_press_name()`, and returns a 2-tuple containing the
36     button number and modifier mask corresponding to `name`. If the parse
37     fails, both values will be 0 (zero).
38
39     """
40     if name is None:
41         return (0, 0)
42     name = str(name)
43     try:
44         mods_s, button_s = name.split("Button", 1)
45         if button_s == '':
46             button = 0
47         else:
48             button = int(button_s)
49     except ValueError:
50         button = 0
51         mods = gdk.ModifierType(0)
52     else:
53         keyval_ignored, mods = gtk.accelerator_parse(mods_s)
54     return button, mods
55
56
57 class ButtonMapping:
58     """Button mapping table.
59
60     An instance resides in the application, and is updated by the preferences
61     window.
62
63     """
64
65     def __init__(self):
66         self._mapping = {}
67         self._modifiers = []
68
69
70     def update(self, mapping):
71         """Updates from a prefs sub-hash.
72
73         :param mapping: dict of button_press_name()s to action names.
74            A reference is not maintained.
75
76         """
77         self._mapping = {}
78         self._modifiers = []
79         for bp_name, action_name in mapping.iteritems():
80             button, modifiers = button_press_parse(bp_name)
81             if not self._mapping.has_key(modifiers):
82                 self._mapping[modifiers] = {}
83             self._mapping[modifiers][button] = action_name
84         self._modifiers.append((modifiers, button, action_name))
85
86
87     def get_unique_action_for_modifiers(self, modifiers, button=1):
88         """Gets a single, unique action name for a modifier mask.
89
90         :param modifiers: a bitmask of GDK Modifier Constants
91         :param button: the button number to require; defaults to 1.
92         :rtype: string containing an action name, or None
93
94         """
95         try:
96             modmap = self._mapping[modifiers]
97             if len(modmap) > 1:
98                 return None
99             return self._mapping[modifiers][button]
100         except KeyError:
101             return None
102
103
104     def lookup(self, modifiers, button):
105         """Look up a single pointer binding efficiently.
106
107         :param modifiers: a bitmask of GDK Modifier Constants.
108         :type modifiers: GdkModifierType or int
109         :param button: a button number
110         :type button: int
111         :rtype: string containing an action name, or None
112
113         """
114         if not self._mapping.has_key(modifiers):
115             return None
116         return self._mapping[modifiers].get(button, None)
117
118
119     def lookup_possibilities(self, modifiers):
120         """Find potential actions, reachable via buttons or more modifiers
121
122         :param modifiers: a bitmask of GDK Modifier Constants.
123         :type modifiers: GdkModifierType or int
124         :rtype: list
125
126         Returns those actions which can be reached from the currently held
127         modifier keys by either pressing a pointer button right now, or by
128         holding down additional modifiers and then pressing a pointer button.
129         If `modifiers` is empty, an empty list will be returned.
130
131         Each element in the returned list is a 3-tuple of the form ``(MODS,
132         BUTTON, ACTION NAME)``.
133
134         """
135         # This enables us to display:
136         #  "<Ctrl>: with <Shift>+Button1, ACTION1; with Button3, ACTION2."
137         # while the modifiers are pressed, but the button isn't. Also if
138         # only a single possibility is returned, the hander should just
139         # enter the mode as a springload (and display what just happened!)
140         possibilities = []
141         for possible, btn, act in self._modifiers:
142             # Exclude bindings whose modifiers do not overlap
143             if not modifiers & possible:
144                 continue
145             # Include only exact mathes, and those possibilies which can be
146             # reached by pressing more modifier keys.
147             if modifiers == possible or ~modifiers & possible:
148                 possibilities.append((possible, btn, action))
149         return possibilities
150
151
152 class ButtonMappingEditor (gtk.EventBox):
153     """Editor for a prefs hash of pointer bindings mapped to action strings.
154
155     """
156
157     def __init__(self, bindings, actions_possible):
158         """Initialise
159
160         :param bindings: Mapping of pointer binding names to their actions. A
161           reference is kept internally, and the entries will be
162           modified.
163         :type bindings: dict of bingings being edited
164         :param actions_possible: List of all possible action strings. The 0th
165           entry in the list is the default.
166         :type actions_possible: indexable sequence
167
168         """
169         gtk.EventBox.__init__(self)
170         self.default_action = actions_possible[0]
171         self.actions = set(actions_possible)
172         self.vbox = gtk.VBox()
173         self.add(self.vbox)
174
175         # Canonicalize <Control> -> <Primary>
176         tmp_bindings = dict(bindings)
177         bindings.clear()
178         for bp_name, action_name in tmp_bindings.iteritems():
179             bp_name = button_press_name(*button_press_parse(bp_name))
180             bindings[bp_name] = action_name
181         self.bindings = bindings  #: dict of bindings being edited
182
183         # Model: combo cellrenderer's liststore
184         ls = gtk.ListStore(gobject.TYPE_STRING)
185         actions_list = list(actions_possible)
186         actions_list.sort()
187         for act in actions_list:
188             ls.append((act,))
189         self.action_liststore = ls
190         self.action_liststore_column = 0
191
192         # Model: main list's liststore
193         # This is reflected into self.bindings when it changes
194         column_types = [gobject.TYPE_STRING, gobject.TYPE_STRING]
195         ls = gtk.ListStore(*column_types)
196         self.action_column = 0
197         self.bp_column = 1
198         for sig in ("row-changed", "row-deleted", "row_inserted"):
199             ls.connect(sig, self._liststore_updated_cb)
200         self.liststore = ls
201
202         # Bindings hash observers, external interface
203         self.bindings_observers = [] #: List of cb(editor) callbacks
204
205         # View: treeview
206         scrolledwin = gtk.ScrolledWindow()
207         tv = gtk.TreeView()
208         tv.set_model(ls)
209         scrolledwin.add(tv)
210         self.vbox.pack_start(scrolledwin, True, True)
211         tv.set_size_request(480, 320)
212         tv.set_headers_clickable(True)
213         self.treeview = tv
214         self.selection = tv.get_selection()
215         self.selection.connect("changed", self._selection_changed_cb)
216
217         # Column 0: action name
218         cell = gtk.CellRendererCombo()
219         cell.set_property("model", self.action_liststore)
220         cell.set_property("text-column", self.action_liststore_column)
221         cell.set_property("mode", gtk.CELL_RENDERER_MODE_EDITABLE)
222         cell.set_property("editable", True)
223         cell.set_property("has-entry", False)
224         cell.connect("edited", self._action_cell_edited_cb)
225         col = gtk.TreeViewColumn(_("Action"), cell)
226         col.add_attribute(cell, "text", self.action_column)
227         col.set_min_width(150)
228         col.set_resizable(False)
229         col.set_expand(False)
230         col.set_sort_column_id(self.action_column)
231         tv.append_column(col)
232
233
234         # Column 1: button press
235         cell = gtk.CellRendererText()
236         cell.set_property("ellipsize", pango.ELLIPSIZE_END)
237         cell.set_property("mode", gtk.CELL_RENDERER_MODE_EDITABLE)
238         cell.set_property("editable", True)
239         cell.connect("edited", self._bp_cell_edited_cb)
240         cell.connect("editing-started", self._bp_cell_editing_started_cb)
241         col = gtk.TreeViewColumn(_("Button press"), cell)
242         col.add_attribute(cell, "text", self.bp_column)
243         col.set_expand(True)
244         col.set_resizable(True)
245         col.set_min_width(200)
246         col.set_sort_column_id(self.bp_column)
247         tv.append_column(col)
248
249         # List editor toolbar (inline-toolbar for gtk3)
250         list_tools = gtk.Toolbar()
251         list_tools.set_style(gtk.TOOLBAR_ICONS)
252         list_tools.set_icon_size(gtk.ICON_SIZE_SMALL_TOOLBAR)
253         if False: # TODO: GTK3
254             context = list_tools.get_style_context()
255             context.add_class("inline-toolbar")
256         self.vbox.pack_start(list_tools, False, False)
257
258         # Add binding
259         btn = gtk.ToolButton()
260         btn.set_tooltip_text(_("Add a new binding"))
261         btn.set_icon_name(gtk.STOCK_ADD)
262         btn.connect("clicked", self._add_button_clicked_cb)
263         list_tools.add(btn)
264
265         # Remove (inactive if list is empty)
266         btn = gtk.ToolButton()
267         btn.set_icon_name(gtk.STOCK_REMOVE)
268         btn.set_tooltip_text(_("Remove the current binding"))
269         btn.connect("clicked", self._remove_button_clicked_cb)
270         list_tools.add(btn)
271         self.remove_button = btn
272
273         # Populate and update the UI
274         self._updating_model = False
275         self._bindings_changed_cb()
276
277
278     def set_bindings(self, bindings):
279         self.bindings = bindings
280         self._bindings_changed_cb()
281
282
283     def _bindings_changed_cb(self):
284         """Updates the editor list to reflect the prefs hash changing.
285         """
286         self._updating_model = True
287         self.liststore.clear()
288         for bp_name, action_name in self.bindings.iteritems():
289             self.liststore.append((action_name, bp_name))
290         self._updating_model = False
291         self._update_list_buttons()
292
293
294     def _liststore_updated_cb(self, ls, *args, **kwargs):
295         if self._updating_model:
296             return
297         iter = ls.get_iter_first()
298         self.bindings.clear()
299         while iter is not None:
300             bp_name, action = ls.get(iter, self.bp_column, self.action_column)
301             if action in self.actions and bp_name is not None:
302                 self.bindings[bp_name] = action
303             iter = ls.iter_next(iter)
304         self._update_list_buttons()
305         for func in self.bindings_observers:
306             func(self)
307
308
309     def _selection_changed_cb(self, selection):
310         if self._updating_model:
311             return
312         self._update_list_buttons()
313
314
315     def _update_list_buttons(self):
316         is_populated = len(self.bindings)>0
317         has_selected = self.selection.count_selected_rows()>0
318         self.remove_button.set_sensitive(is_populated and has_selected)
319
320
321     def _add_button_clicked_cb(self, button):
322         added_iter = self.liststore.append((self.default_action, None))
323         self.selection.select_iter(added_iter)
324         added_path = self.liststore.get_path(added_iter)
325         focus_col = self.treeview.get_column(self.action_column)
326         self.treeview.set_cursor_on_cell(added_path, focus_col, None, True)
327
328
329     def _remove_button_clicked_cb(self, button):
330         if self.selection.count_selected_rows() > 0:
331             ls, selected = self.selection.get_selected()
332             ls.remove(selected)
333
334
335     ## "Controller" callbacks
336
337     def _action_cell_edited_cb(self, cell, path, action_name):
338         iter = self.liststore.get_iter(path)
339         self.liststore.set_value(iter, self.action_column, action_name)
340         self.treeview.columns_autosize()
341         # If we don't have a button-press name yet, edit that next
342         bp_name = self.liststore.get_value(iter, self.bp_column)
343         if bp_name is None:
344             focus_col = self.treeview.get_column(self.bp_column)
345             self.treeview.set_cursor_on_cell(path, focus_col, None, True)
346
347
348     def _bp_cell_edited_cb(self, cell, path, bp_name):
349         iter = self.liststore.get_iter(path)
350         bp_name_old = self.liststore.get_value(iter, self.bp_column)
351         self.liststore.set_value(iter, self.bp_column, bp_name)
352
353
354     def _bp_cell_editing_started_cb(self, cell, editable, path):
355         iter = self.liststore.get_iter(path)
356         action_name = self.liststore.get_value(iter, self.action_column)
357         bp_name = self.liststore.get_value(iter, self.bp_column)
358
359         editable.set_sensitive(False)
360         dialog = gtk.Dialog()
361         dialog.set_extension_events(gdk.EXTENSION_EVENTS_ALL)
362         dialog.set_modal(True)
363         dialog.set_title(_("Edit binding for '%s'") % action_name)
364         dialog.set_transient_for(self.get_toplevel())
365         dialog.set_position(gtk.WIN_POS_CENTER_ON_PARENT)
366         dialog.add_buttons(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL,
367                            gtk.STOCK_OK, gtk.RESPONSE_OK)
368         dialog.set_default_response(gtk.RESPONSE_OK)
369         dialog.connect("response", self._bp_edit_dialog_response_cb, editable)
370         dialog.ok_btn = dialog.get_widget_for_response(gtk.RESPONSE_OK)
371         dialog.ok_btn.set_sensitive(bp_name is not None)
372
373         evbox = gtk.EventBox()
374         evbox.set_border_width(12)
375         evbox.connect("button-press-event", self._bp_edit_box_button_press_cb,
376                       dialog, editable)
377         evbox.connect("enter-notify-event", self._bp_edit_box_enter_cb)
378
379         table = gtk.Table(3, 2)
380         table.set_row_spacings(12)
381         table.set_col_spacings(12)
382
383         row = 0
384         label = gtk.Label()
385         label.set_alignment(0, 0.5)
386         label.set_text(_("Action:"))
387         table.attach(label, 0, 1, row, row+1, gtk.FILL)
388
389         label = gtk.Label()
390         label.set_alignment(0, 0.5)
391         label.set_text(str(action_name))
392         table.attach(label, 1, 2, row, row+1, gtk.FILL|gtk.EXPAND)
393
394         row += 1
395         label = gtk.Label()
396         label.set_alignment(0, 0.5)
397         label.set_text(_("Button press:"))
398         table.attach(label, 0, 1, row, row+1, gtk.FILL)
399
400         label = gtk.Label()
401         label.set_alignment(0, 0.5)
402         label.set_text(str(bp_name))
403         dialog.bp_name = bp_name
404         dialog.bp_name_orig = bp_name
405         dialog.bp_label = label
406         table.attach(label, 1, 2, row, row+1, gtk.FILL|gtk.EXPAND)
407
408         row += 1
409         label = gtk.Label()
410         label.set_size_request(300, 75)
411         label.set_alignment(0, 0)
412         label.set_line_wrap(True)
413         dialog.hint_label = label
414         self._bp_edit_dialog_set_standard_hint(dialog)
415         table.attach(label, 0, 2, row, row+1,
416                      gtk.FILL|gtk.EXPAND, gtk.FILL|gtk.EXPAND,
417                      0, 12)
418
419         evbox.add(table)
420         dialog.get_content_area().pack_start(evbox, True, True)
421         evbox.show_all()
422
423         dialog.show()
424
425     def _bp_edit_dialog_set_error(self, dialog, markup):
426         dialog.hint_label.set_markup(
427                 "<span foreground='red'>%s</span>"
428                 % markup)
429
430     def _bp_edit_dialog_set_standard_hint(self, dialog):
431         markup = _("Hold down modifier keys, and press a button "
432                    "over this text to set a new binding.")
433         dialog.hint_label.set_markup(markup)
434
435     def _bp_edit_box_enter_cb(self, evbox, event):
436         evbox.get_window().set_cursor(gdk.Cursor(gdk.MOUSE))
437
438     def _bp_edit_dialog_response_cb(self, dialog, response_id, editable):
439         if response_id == gtk.RESPONSE_OK:
440             if dialog.bp_name is not None:
441                 editable.set_text(dialog.bp_name)
442             editable.editing_done()
443         editable.remove_widget()
444         dialog.destroy()
445
446     def _bp_edit_box_button_press_cb(self, evbox, event, dialog, editable):
447         modifiers = event.state & gtk.accelerator_get_default_mod_mask()
448         bp_name = button_press_name(event.button, modifiers)
449         action = None
450         if bp_name != dialog.bp_name_orig:
451             action = self.bindings.get(bp_name, None)
452         if action is not None:
453             self._bp_edit_dialog_set_error(dialog, _(
454                   "%s is already bound to the action '%s'")
455                   % (escape(str(bp_name)), escape(str(action))))
456             dialog.ok_btn.set_sensitive(False)
457         else:
458             self._bp_edit_dialog_set_standard_hint(dialog)
459             dialog.bp_name = bp_name
460             dialog.bp_label.set_text(str(bp_name))
461             dialog.ok_btn.set_sensitive(True)
462             dialog.ok_btn.grab_focus()
463
464
465