1# This file is part of MyPaint. 2# Copyright (C) 2012-2019 by the MyPaint Development Team. 3# 4# This program is free software; you can redistribute it and/or modify 5# it under the terms of the GNU General Public License as published by 6# the Free Software Foundation; either version 2 of the License, or 7# (at your option) any later version. 8 9"""Button press mapping.""" 10 11from __future__ import division, print_function 12from gettext import gettext as _ 13import logging 14 15from lib.gibindings import Gtk 16from lib.gibindings import Gdk 17from lib.gibindings import GObject 18from lib.gibindings import Pango 19 20import lib.xml 21from . import widgets 22from lib.pycompat import unicode 23 24logger = logging.getLogger(__name__) 25 26 27def button_press_name(button, mods): 28 """Converts button number & modifier mask to a prefs-storable string. 29 30 Analogous to `Gtk.accelerator_name()`. Buttonpress names look similar to 31 GDK accelerator names, for example ``<Control><Shift>Button2`` or 32 ``<Primary><Alt>Button4`` for newer versions of GTK. If the button is 33 equal to zero (see `button_press_parse()`), `None` is returned. 34 35 """ 36 button = int(button) 37 mods = int(mods) 38 if button <= 0: 39 return None 40 mods = Gdk.ModifierType(mods) 41 modif_name = Gtk.accelerator_name(0, mods) 42 return modif_name + "Button%d" % (button,) 43 44 45def button_press_displayname(button, mods, shorten = False): 46 """Converts a button number & modifier mask to a localized unicode string. 47 """ 48 button = int(button) 49 mods = int(mods) 50 if button <= 0: 51 return None 52 mods = Gdk.ModifierType(mods) 53 modif_label = Gtk.accelerator_get_label(0, mods) 54 modif_label = unicode(modif_label) 55 separator = "" 56 if modif_label: 57 separator = u"+" 58 # TRANSLATORS: "Button" refers to a mouse button 59 # TRANSLATORS: It is part of a button map label. 60 mouse_button_label = _("Button") 61 if shorten: 62 # TRANSLATORS: abbreviated "Button <number>" for forms like "Alt+Btn1" 63 mouse_button_label = _("Btn") 64 return "{modifiers}{plus}{btn}{button_number}".format( 65 modifiers=modif_label, 66 plus=separator, 67 btn=mouse_button_label, 68 button_number=button, 69 ) 70 71 72def button_press_parse(name): 73 """Converts button press names to a button number & modifier mask. 74 75 Analogous to `Gtk.accelerator_parse()`. This function parses the strings 76 created by `button_press_name()`, and returns a 2-tuple containing the 77 button number and modifier mask corresponding to `name`. If the parse 78 fails, both values will be 0 (zero). 79 80 """ 81 if name is None: 82 return (0, 0) 83 name = str(name) 84 try: 85 mods_s, button_s = name.split("Button", 1) 86 if button_s == '': 87 button = 0 88 else: 89 button = int(button_s) 90 except ValueError: 91 button = 0 92 mods = Gdk.ModifierType(0) 93 else: 94 keyval_ignored, mods = Gtk.accelerator_parse(mods_s) 95 return button, mods 96 97 98def get_handler_object(app, action_name): 99 """Find a (nominal) handler for a named buttonmap action. 100 101 :param app: MyPaint application instance to use for the lookup 102 :param action_name: machine-readable action name string. 103 :rtype: tuple of the form (handler_type, handler_obj) 104 105 Defined handler_type strings and their handler_objs are: "mode_class" (an 106 instantiable InteractionMode class), "popup_state" (an activatable popup 107 state), "gtk_action" (an activatable Gtk.Action), or "no_handler" (the 108 value None). 109 110 """ 111 from gui.mode import ModeRegistry, InteractionMode 112 mode_class = ModeRegistry.get_mode_class(action_name) 113 if mode_class is not None: 114 assert issubclass(mode_class, InteractionMode) 115 return ("mode_class", mode_class) 116 elif action_name in app.drawWindow.popup_states: 117 popup_state = app.drawWindow.popup_states[action_name] 118 return ("popup_state", popup_state) 119 else: 120 action = app.find_action(action_name) 121 if action is not None: 122 return ("gtk_action", action) 123 else: 124 return ("no_handler", None) 125 126 127class ButtonMapping (object): 128 """Button mapping table. 129 130 An instance resides in the application, and is updated by the preferences 131 window. 132 133 """ 134 135 def __init__(self): 136 super(ButtonMapping, self).__init__() 137 self._mapping = {} 138 self._modifiers = [] 139 140 def update(self, mapping): 141 """Updates from a prefs sub-hash. 142 143 :param mapping: dict of button_press_name()s to action names. 144 A reference is not maintained. 145 146 """ 147 self._mapping = {} 148 self._modifiers = [] 149 for bp_name, action_name in mapping.items(): 150 button, modifiers = button_press_parse(bp_name) 151 if modifiers not in self._mapping: 152 self._mapping[modifiers] = {} 153 self._mapping[modifiers][button] = action_name 154 self._modifiers.append((modifiers, button, action_name)) 155 156 def get_unique_action_for_modifiers(self, modifiers, button=1): 157 """Gets a single, unique action name for a modifier mask. 158 159 :param modifiers: a bitmask of GDK Modifier Constants 160 :param button: the button number to require; defaults to 1. 161 :rtype: string containing an action name, or None 162 163 """ 164 try: 165 modmap = self._mapping[modifiers] 166 if len(modmap) > 1: 167 return None 168 return self._mapping[modifiers][button] 169 except KeyError: 170 return None 171 172 def lookup(self, modifiers, button): 173 """Look up a single pointer binding efficiently. 174 175 :param modifiers: a bitmask of GDK Modifier Constants. 176 :type modifiers: GdkModifierType or int 177 :param button: a button number 178 :type button: int 179 :rtype: string containing an action name, or None 180 181 """ 182 if modifiers not in self._mapping: 183 return None 184 return self._mapping[modifiers].get(button, None) 185 186 def lookup_possibilities(self, modifiers): 187 """Find potential actions, reachable via buttons or more modifiers 188 189 :param modifiers: a bitmask of GDK Modifier Constants. 190 :type modifiers: GdkModifierType or int 191 :rtype: list 192 193 Returns those actions which can be reached from the currently held 194 modifier keys by either pressing a pointer button right now, or by 195 holding down additional modifiers and then pressing a pointer button. 196 If `modifiers` is empty, an empty list will be returned. 197 198 Each element in the returned list is a 3-tuple of the form ``(MODS, 199 BUTTON, ACTION NAME)``. 200 201 """ 202 # This enables us to display: 203 # "<Ctrl>: with <Shift>+Button1, ACTION1; with Button3, ACTION2." 204 # while the modifiers are pressed, but the button isn't. Also if 205 # only a single possibility is returned, the handler should just 206 # enter the mode as a springload (and display what just happened!) 207 possibilities = [] 208 for possible, btn, action in self._modifiers: 209 # Exclude possible bindings whose modifiers do not overlap 210 if (modifiers & possible) != modifiers: 211 continue 212 # Include only exact matches, and those possibilities which can be 213 # reached by pressing more modifier keys. 214 if modifiers == possible or ~modifiers & possible: 215 possibilities.append((possible, btn, action)) 216 return possibilities 217 218 219class ButtonMappingEditor (Gtk.EventBox): 220 """Editor for a prefs hash of pointer bindings mapped to action strings. 221 222 """ 223 224 __gtype_name__ = 'ButtonMappingEditor' 225 226 def __init__(self): 227 """Initialise. 228 """ 229 super(ButtonMappingEditor, self).__init__() 230 import gui.application 231 self.app = gui.application.get_app() 232 self.actions = set() 233 self.default_action = None 234 self.bindings = None #: dict of bindings being edited 235 self.vbox = Gtk.VBox() 236 self.add(self.vbox) 237 238 # Display strings for action names 239 self.action_labels = dict() 240 241 # Model: combo cellrenderer's liststore 242 ls = Gtk.ListStore(GObject.TYPE_STRING, GObject.TYPE_STRING) 243 self.action_liststore = ls 244 self.action_liststore_value_column = 0 245 self.action_liststore_display_column = 1 246 247 # Model: main list's liststore 248 # This is reflected into self.bindings when it changes 249 column_types = [GObject.TYPE_STRING] * 3 250 ls = Gtk.ListStore(*column_types) 251 self.action_column = 0 252 self.bp_column = 1 253 self.bpd_column = 2 254 for sig in ("row-changed", "row-deleted", "row_inserted"): 255 ls.connect(sig, self._liststore_updated_cb) 256 self.liststore = ls 257 258 # Bindings hash observers, external interface 259 self.bindings_observers = [] #: List of cb(editor) callbacks 260 261 # View: treeview 262 scrolledwin = Gtk.ScrolledWindow() 263 scrolledwin.set_shadow_type(Gtk.ShadowType.IN) 264 tv = Gtk.TreeView() 265 tv.set_model(ls) 266 scrolledwin.add(tv) 267 self.vbox.pack_start(scrolledwin, True, True, 0) 268 tv.set_size_request(480, 320) 269 tv.set_headers_clickable(True) 270 self.treeview = tv 271 self.selection = tv.get_selection() 272 self.selection.connect("changed", self._selection_changed_cb) 273 274 # Column 0: action name 275 cell = Gtk.CellRendererCombo() 276 cell.set_property("model", self.action_liststore) 277 cell.set_property("text-column", self.action_liststore_display_column) 278 cell.set_property("mode", Gtk.CellRendererMode.EDITABLE) 279 cell.set_property("editable", True) 280 cell.set_property("has-entry", False) 281 cell.connect("changed", self._action_cell_changed_cb) 282 # TRANSLATORS: Name of first column in the button map preferences. 283 # TRANSLATORS: Refers to an action bound to a mod+button combination. 284 col = Gtk.TreeViewColumn(_("Action"), cell) 285 col.set_cell_data_func(cell, self._liststore_action_datafunc) 286 col.set_min_width(150) 287 col.set_resizable(False) 288 col.set_expand(False) 289 col.set_sort_column_id(self.action_column) 290 tv.append_column(col) 291 292 # Column 1: button press 293 cell = Gtk.CellRendererText() 294 cell.set_property("ellipsize", Pango.EllipsizeMode.END) 295 cell.set_property("mode", Gtk.CellRendererMode.EDITABLE) 296 cell.set_property("editable", True) 297 cell.connect("edited", self._bp_cell_edited_cb) 298 cell.connect("editing-started", self._bp_cell_editing_started_cb) 299 # TRANSLATORS: Name of second column in the button map preferences. 300 # TRANSLATORS: Column lists mod+button combinations (bound to actions) 301 # TRANSLATORS: E.g. Button1 or Ctrl+Button2 or Alt+Button3 302 col = Gtk.TreeViewColumn(_("Button press"), cell) 303 col.add_attribute(cell, "text", self.bpd_column) 304 col.set_expand(True) 305 col.set_resizable(True) 306 col.set_min_width(200) 307 col.set_sort_column_id(self.bpd_column) 308 tv.append_column(col) 309 310 # List editor toolbar 311 list_tools = Gtk.Toolbar() 312 list_tools.set_style(Gtk.ToolbarStyle.ICONS) 313 list_tools.set_icon_size(widgets.ICON_SIZE_LARGE) 314 context = list_tools.get_style_context() 315 context.add_class("inline-toolbar") 316 self.vbox.pack_start(list_tools, False, False, 0) 317 318 # Add binding 319 btn = Gtk.ToolButton() 320 btn.set_tooltip_text(_("Add a new binding")) 321 btn.set_icon_name("mypaint-add-symbolic") 322 btn.connect("clicked", self._add_button_clicked_cb) 323 list_tools.add(btn) 324 325 # Remove (inactive if list is empty) 326 btn = Gtk.ToolButton() 327 btn.set_icon_name("mypaint-remove-symbolic") 328 btn.set_tooltip_text(_("Remove the current binding")) 329 btn.connect("clicked", self._remove_button_clicked_cb) 330 list_tools.add(btn) 331 self.remove_button = btn 332 333 self._updating_model = False 334 335 def set_actions(self, actions): 336 """Sets the internal list of possible actions. 337 338 :param actions: List of all possible action strings. The 0th 339 entry in the list is the default. 340 :type actions: indexable sequence 341 342 """ 343 self.default_action = actions[0] 344 self.actions = set(actions) 345 labels_list = sorted((self._get_action_label(a), a) for a in actions) 346 self.action_liststore.clear() 347 for label, act in labels_list: 348 self.action_labels[act] = label 349 self.action_liststore.append((act, label)) 350 351 def _liststore_action_datafunc(self, column, cell, model, iter, 352 *user_data): 353 action_name = model.get_value(iter, self.action_column) 354 label = self.action_labels.get(action_name, action_name) 355 cell.set_property("text", label) 356 357 def _get_action_label(self, action_name): 358 # Get a displayable (and translated) string for an action name 359 handler_type, handler = get_handler_object(self.app, action_name) 360 action_label = action_name 361 if handler_type == 'gtk_action': 362 action_label = handler.get_label() 363 elif handler_type == 'popup_state': 364 action_label = handler.label 365 elif handler_type == 'mode_class': 366 action_label = action_name 367 if handler.ACTION_NAME is not None: 368 action = self.app.find_action(handler.ACTION_NAME) 369 if action is not None: 370 action_label = action.get_label() 371 if action_label is None: 372 action_label = "" # Py3+: str cannot be compared to None 373 return action_label 374 375 def set_bindings(self, bindings): 376 """Sets the mapping of binding names to actions. 377 378 :param bindings: Mapping of pointer binding names to their actions. A 379 reference is kept internally, and the entries will be 380 modified. 381 :type bindings: dict of bindings being edited 382 383 The binding names in ``bindings`` will be canonicalized from the older 384 ``<Control>`` prefix to ``<Primary>`` if supported by this Gtk. 385 386 """ 387 tmp_bindings = dict(bindings) 388 bindings.clear() 389 for bp_name, action_name in tmp_bindings.items(): 390 bp_name = button_press_name(*button_press_parse(bp_name)) 391 bindings[bp_name] = action_name 392 self.bindings = bindings 393 self._bindings_changed_cb() 394 395 def _bindings_changed_cb(self): 396 """Updates the editor list to reflect the prefs hash changing. 397 """ 398 self._updating_model = True 399 self.liststore.clear() 400 for bp_name, action_name in self.bindings.items(): 401 bp_displayname = button_press_displayname( 402 *button_press_parse(bp_name)) 403 self.liststore.append((action_name, bp_name, bp_displayname)) 404 self._updating_model = False 405 self._update_list_buttons() 406 407 def _liststore_updated_cb(self, ls, *args, **kwargs): 408 if self._updating_model: 409 return 410 iter = ls.get_iter_first() 411 self.bindings.clear() 412 while iter is not None: 413 bp_name, action = ls.get(iter, self.bp_column, self.action_column) 414 if action in self.actions and bp_name is not None: 415 self.bindings[bp_name] = action 416 iter = ls.iter_next(iter) 417 self._update_list_buttons() 418 for func in self.bindings_observers: 419 func(self) 420 421 def _selection_changed_cb(self, selection): 422 if self._updating_model: 423 return 424 self._update_list_buttons() 425 426 def _update_list_buttons(self): 427 is_populated = len(self.bindings) > 0 428 has_selected = self.selection.count_selected_rows() > 0 429 self.remove_button.set_sensitive(is_populated and has_selected) 430 431 def _add_button_clicked_cb(self, button): 432 added_iter = self.liststore.append((self.default_action, None, None)) 433 self.selection.select_iter(added_iter) 434 added_path = self.liststore.get_path(added_iter) 435 focus_col = self.treeview.get_column(self.action_column) 436 self.treeview.set_cursor_on_cell(added_path, focus_col, None, True) 437 438 def _remove_button_clicked_cb(self, button): 439 if self.selection.count_selected_rows() > 0: 440 ls, selected = self.selection.get_selected() 441 ls.remove(selected) 442 443 ## "Controller" callbacks 444 445 def _action_cell_changed_cb(self, combo, path_string, new_iter, *etc): 446 action_name = self.action_liststore.get_value( 447 new_iter, 448 self.action_liststore_value_column 449 ) 450 iter = self.liststore.get_iter(path_string) 451 self.liststore.set_value(iter, self.action_column, action_name) 452 self.treeview.columns_autosize() 453 # If we don't have a button-press name yet, edit that next 454 bp_name = self.liststore.get_value(iter, self.bp_column) 455 if bp_name is None: 456 focus_col = self.treeview.get_column(self.bp_column) 457 tree_path = Gtk.TreePath(path_string) 458 self.treeview.set_cursor_on_cell(tree_path, focus_col, None, True) 459 460 def _bp_cell_edited_cb(self, cell, path, bp_name): 461 iter = self.liststore.get_iter(path) 462 bp_displayname = button_press_displayname(*button_press_parse(bp_name)) 463 self.liststore.set_value(iter, self.bp_column, bp_name) 464 self.liststore.set_value(iter, self.bpd_column, bp_displayname) 465 466 def _bp_cell_editing_started_cb(self, cell, editable, path): 467 iter = self.liststore.get_iter(path) 468 action_name = self.liststore.get_value(iter, self.action_column) 469 bp_name = self.liststore.get_value(iter, self.bp_column) 470 bp_displayname = button_press_displayname(*button_press_parse(bp_name)) 471 472 editable.set_sensitive(False) 473 dialog = Gtk.Dialog() 474 dialog.set_modal(True) 475 dialog.set_title(_("Edit binding for '%s'") % action_name) 476 dialog.set_transient_for(self.get_toplevel()) 477 dialog.set_position(Gtk.WindowPosition.CENTER_ON_PARENT) 478 dialog.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, 479 Gtk.STOCK_OK, Gtk.ResponseType.OK) 480 dialog.set_default_response(Gtk.ResponseType.OK) 481 dialog.connect("response", self._bp_edit_dialog_response_cb, editable) 482 dialog.ok_btn = dialog.get_widget_for_response(Gtk.ResponseType.OK) 483 dialog.ok_btn.set_sensitive(bp_name is not None) 484 485 evbox = Gtk.EventBox() 486 evbox.set_border_width(12) 487 evbox.connect("button-press-event", self._bp_edit_box_button_press_cb, 488 dialog, editable) 489 evbox.connect("enter-notify-event", self._bp_edit_box_enter_cb) 490 491 table = Gtk.Table(3, 2) 492 table.set_row_spacings(12) 493 table.set_col_spacings(12) 494 495 row = 0 496 label = Gtk.Label() 497 label.set_alignment(0, 0.5) 498 # TRANSLATORS: Part of interface when adding a new button map binding. 499 # TRANSLATORS: It's a label for the action part of the combination. 500 # TRANSLATORS: Probably always the same as the column name 501 # TRANSLATORS: "Action" with a trailing ":" or lang-specific symbol 502 label.set_text(_("Action:")) 503 table.attach(label, 0, 1, row, row + 1, Gtk.AttachOptions.FILL) 504 505 label = Gtk.Label() 506 label.set_alignment(0, 0.5) 507 label.set_text(str(action_name)) 508 table.attach( 509 label, 1, 2, row, row + 1, 510 Gtk.AttachOptions.FILL | Gtk.AttachOptions.EXPAND) 511 512 row += 1 513 label = Gtk.Label() 514 label.set_alignment(0, 0.5) 515 # TRANSLATORS: Part of interface when adding a new button map binding. 516 # TRANSLATORS: It's a label for the mod+button part of the combination. 517 # TRANSLATORS: Probably always the same as "Button press" (column name) 518 # TRANSLATORS: but with a trailing ":" or other lang-specific symbol. 519 label.set_text(_("Button press:")) 520 table.attach(label, 0, 1, row, row + 1, Gtk.AttachOptions.FILL) 521 522 label = Gtk.Label() 523 label.set_alignment(0, 0.5) 524 label.set_text(str(bp_displayname)) 525 dialog.bp_name = bp_name 526 dialog.bp_name_orig = bp_name 527 dialog.bp_label = label 528 table.attach( 529 label, 1, 2, row, row + 1, 530 Gtk.AttachOptions.FILL | Gtk.AttachOptions.EXPAND) 531 532 row += 1 533 label = Gtk.Label() 534 label.set_size_request(300, 75) 535 label.set_alignment(0, 0) 536 label.set_line_wrap(True) 537 dialog.hint_label = label 538 self._bp_edit_dialog_set_standard_hint(dialog) 539 table.attach( 540 label, 0, 2, row, row + 1, 541 Gtk.AttachOptions.FILL | Gtk.AttachOptions.EXPAND, 542 Gtk.AttachOptions.FILL | Gtk.AttachOptions.EXPAND, 543 0, 12) 544 545 evbox.add(table) 546 dialog.get_content_area().pack_start(evbox, True, True, 0) 547 evbox.show_all() 548 549 dialog.show() 550 551 def _bp_edit_dialog_set_error(self, dialog, markup): 552 dialog.hint_label.set_markup( 553 "<span foreground='red'>%s</span>" % markup) 554 555 def _bp_edit_dialog_set_standard_hint(self, dialog): 556 markup = _("Hold down modifier keys, and press a button " 557 "over this text to set a new binding.") 558 dialog.hint_label.set_markup(markup) 559 560 def _bp_edit_box_enter_cb(self, evbox, event): 561 window = evbox.get_window() 562 disp = window.get_display() 563 try: # Wayland themes are a bit incomplete 564 cursor = Gdk.Cursor.new_for_display(disp, Gdk.CursorType.CROSSHAIR) 565 window.set_cursor(cursor) 566 except Exception: 567 logger.exception("Cursor setting failed") # and otherwise ignore 568 569 def _bp_edit_dialog_response_cb(self, dialog, response_id, editable): 570 if response_id == Gtk.ResponseType.OK: 571 if dialog.bp_name is not None: 572 editable.set_text(dialog.bp_name) 573 editable.editing_done() 574 editable.remove_widget() 575 dialog.destroy() 576 577 def _bp_edit_box_button_press_cb(self, evbox, event, dialog, editable): 578 modifiers = event.state & Gtk.accelerator_get_default_mod_mask() 579 bp_name = button_press_name(event.button, modifiers) 580 bp_displayname = button_press_displayname(event.button, modifiers) 581 if modifiers == 0 and event.button == 1: 582 self._bp_edit_dialog_set_error( 583 dialog, 584 # TRANSLATORS: "fixed" in the sense of "static" - 585 # TRANSLATORS: something which cannot be changed 586 _("{button} cannot be bound without modifier keys " 587 "(its meaning is fixed, sorry)") 588 .format( 589 button=lib.xml.escape(bp_displayname), 590 ), 591 ) 592 dialog.ok_btn.set_sensitive(False) 593 return 594 action = None 595 if bp_name != dialog.bp_name_orig: 596 action = self.bindings.get(bp_name, None) 597 if action is not None: 598 action_label = self.action_labels.get(action, action) 599 self._bp_edit_dialog_set_error( 600 dialog, 601 _("{button_combination} is already bound " 602 "to the action '{action_name}'") 603 .format( 604 button_combination=lib.xml.escape(str(bp_displayname)), 605 action_name=lib.xml.escape(str(action_label)), 606 ), 607 ) 608 609 dialog.ok_btn.set_sensitive(False) 610 else: 611 self._bp_edit_dialog_set_standard_hint(dialog) 612 dialog.bp_name = bp_name 613 dialog.bp_label.set_text(str(bp_displayname)) 614 dialog.ok_btn.set_sensitive(True) 615 dialog.ok_btn.grab_focus() 616