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