1# This file is part of MyPaint.
2# -*- coding: utf-8 -*-
3# Copyright (C) 2014-2019 by the MyPaint Development Team.
4#
5# This program is free software; you can redistribute it and/or modify
6# it under the terms of the GNU General Public License as published by
7# the Free Software Foundation; either version 2 of the License, or
8# (at your option) any later version.
9
10"""Device specific settings and configuration"""
11
12
13## Imports
14
15from __future__ import division, print_function
16import logging
17import collections
18import re
19
20from lib.gettext import C_
21from lib.gibindings import Gtk
22from lib.gibindings import Gdk
23from lib.gibindings import Pango
24
25from lib.observable import event
26import gui.application
27import gui.mode
28
29logger = logging.getLogger(__name__)
30
31## Device prefs
32
33# The per-device settings are stored in the prefs in a sub-dict whose
34# string keys are formed from the device name and enough extra
35# information to (hopefully) identify the device uniquely. Names are not
36# unique, and IDs vary according to the order in which you plug devices
37# in. So for now, our unique strings use a combination of the device's
38# name, its source as presented by GDK, and the number of axes.
39
40_PREFS_ROOT = "input.devices"
41_PREFS_DEVICE_SUBKEY_FMT = "{name}:{source}:{num_axes}"
42
43
44## Device type strings
45
46_DEVICE_TYPE_STRING = {
47    Gdk.InputSource.CURSOR: C_(
48        "prefs: device's type label",
49        "Cursor/puck",
50    ),
51    Gdk.InputSource.ERASER: C_(
52        "prefs: device's type label",
53        "Eraser",
54    ),
55    Gdk.InputSource.KEYBOARD: C_(
56        "prefs: device's type label",
57        "Keyboard",
58    ),
59    Gdk.InputSource.MOUSE: C_(
60        "prefs: device's type label",
61        "Mouse",
62    ),
63    Gdk.InputSource.PEN: C_(
64        "prefs: device's type label",
65        "Pen",
66    ),
67    Gdk.InputSource.TOUCHPAD: C_(
68        "prefs: device's type label",
69        "Touchpad",
70    ),
71    Gdk.InputSource.TOUCHSCREEN: C_(
72        "prefs: device's type label",
73        "Touchscreen",
74    ),
75}
76
77
78## Settings consts and classes
79
80
81class AllowedUsage:
82    """Consts describing how a device may interact with the canvas"""
83
84    ANY = "any"  #: Device can be used for any tasks.
85    NOPAINT = "nopaint"  #: No direct painting, but can manipulate objects.
86    NAVONLY = "navonly"  #: Device can only be used for navigation.
87    IGNORED = "ignored"  #: Device cannot interact with the canvas at all.
88
89    VALUES = (ANY, IGNORED, NOPAINT, NAVONLY)
90    DISPLAY_STRING = {
91        IGNORED: C_(
92            "device settings: allowed usage",
93            u"Ignore",
94        ),
95        ANY: C_(
96            "device settings: allowed usage",
97            u"Any Task",
98        ),
99        NOPAINT: C_(
100            "device settings: allowed usage",
101            u"Non-painting tasks",
102        ),
103        NAVONLY: C_(
104            "device settings: allowed usage",
105            u"Navigation only",
106        ),
107    }
108    BEHAVIOR_MASK = {
109        ANY: gui.mode.Behavior.ALL,
110        IGNORED: gui.mode.Behavior.NONE,
111        NOPAINT: gui.mode.Behavior.NON_PAINTING,
112        NAVONLY: gui.mode.Behavior.CHANGE_VIEW,
113    }
114
115
116class ScrollAction:
117    """Consts describing how a device's scroll events should be used.
118
119    The user can assign one of these values to a device to configure
120    whether they'd prefer panning or scrolling for unmodified scroll
121    events. This setting can be queried via the device monitor.
122
123    """
124
125    ZOOM = "zoom"  #: Alter the canvas scaling
126    PAN = "pan"   #: Pan across the canvas
127
128    VALUES = (ZOOM, PAN)
129    DISPLAY_STRING = {
130        ZOOM: C_("device settings: unmodified scroll action", u"Zoom"),
131        PAN: C_("device settings: unmodified scroll action", u"Pan"),
132    }
133
134
135class Settings (object):
136    """A device's settings"""
137
138    DEFAULT_USAGE = AllowedUsage.VALUES[0]
139    DEFAULT_SCROLL = ScrollAction.VALUES[0]
140
141    def __init__(self, prefs, usage=DEFAULT_USAGE, scroll=DEFAULT_SCROLL):
142        super(Settings, self).__init__()
143        self._usage = self.DEFAULT_USAGE
144        self._update_usage_mask()
145        self._scroll = self.DEFAULT_SCROLL
146        self._prefs = prefs
147        self._load_from_prefs()
148
149    @property
150    def usage(self):
151        return self._usage
152
153    @usage.setter
154    def usage(self, value):
155        if value not in AllowedUsage.VALUES:
156            raise ValueError("Unrecognized usage value")
157        self._usage = value
158        self._update_usage_mask()
159        self._save_to_prefs()
160
161    @property
162    def usage_mask(self):
163        return self._usage_mask
164
165    @property
166    def scroll(self):
167        return self._scroll
168
169    @scroll.setter
170    def scroll(self, value):
171        if value not in ScrollAction.VALUES:
172            raise ValueError("Unrecognized scroll value")
173        self._scroll = value
174        self._save_to_prefs()
175
176    def _load_from_prefs(self):
177        usage = self._prefs.get("usage", self.DEFAULT_USAGE)
178        if usage not in AllowedUsage.VALUES:
179            usage = self.DEFAULT_USAGE
180        self._usage = usage
181        scroll = self._prefs.get("scroll", self.DEFAULT_SCROLL)
182        if scroll not in ScrollAction.VALUES:
183            scroll = self.DEFAULT_SCROLL
184        self._scroll = scroll
185        self._update_usage_mask()
186
187    def _save_to_prefs(self):
188        self._prefs.update({
189            "usage": self._usage,
190            "scroll": self._scroll,
191        })
192
193    def _update_usage_mask(self):
194        self._usage_mask = AllowedUsage.BEHAVIOR_MASK[self._usage]
195
196
197## Main class defs
198
199
200class Monitor (object):
201    """Monitors device use & plugging, and manages their configuration
202
203    An instance resides in the main application. It is responsible for
204    monitoring known devices, determining their characteristics, and
205    storing their settings. Per-device settings are stored in the main
206    application preferences.
207
208    """
209
210    def __init__(self, app):
211        """Initializes, assigning initial input device uses
212
213        :param app: the owning Application instance.
214        :type app: gui.application.Application
215        """
216        super(Monitor, self).__init__()
217        self._app = app
218        if app is not None:
219            self._prefs = app.preferences
220        else:
221            self._prefs = {}
222        if _PREFS_ROOT not in self._prefs:
223            self._prefs[_PREFS_ROOT] = {}
224
225        # Transient device information
226        self._device_settings = collections.OrderedDict()  # {dev: settings}
227        self._last_event_device = None
228        self._last_pen_device = None
229
230        disp = Gdk.Display.get_default()
231        mgr = disp.get_device_manager()
232        mgr.connect("device-added", self._device_added_cb)
233        mgr.connect("device-removed", self._device_removed_cb)
234        self._device_manager = mgr
235
236        for physical_device in mgr.list_devices(Gdk.DeviceType.SLAVE):
237            self._init_device_settings(physical_device)
238
239    ## Devices list
240
241    def get_device_settings(self, device):
242        """Gets the settings for a device
243
244        :param Gdk.Device device: a physical ("slave") device
245        :returns: A settings object which can be manipulated, or None
246        :rtype: Settings
247
248        Changes to the returned object made via its API are saved to the
249        user preferences immediately.
250
251        If the device is a keyboard, or is otherwise unsuitable as a
252        pointing device, None is returned instead. The caller needs to
253        check this case.
254
255        """
256        self._init_device_settings(device)
257        return self._device_settings.get(device)
258
259    def _init_device_settings(self, device):
260        """Ensures that the device settings are loaded for a device"""
261        source = device.get_source()
262        if source == Gdk.InputSource.KEYBOARD:
263            return
264        num_axes = device.get_n_axes()
265        if num_axes < 2:
266            return
267        settings = self._device_settings.get(device)
268        if not settings:
269            try:
270                vendor_id = device.get_vendor_id()
271                product_id = device.get_product_id()
272            except AttributeError:
273                # New in GDK 3.16
274                vendor_id = "?"
275                product_id = "?"
276            logger.info(
277                "New device %r"
278                " (%s, axes:%d, class=%s, vendor=%r, product=%r)",
279                device.get_name(),
280                source.value_name,
281                num_axes,
282                device.__class__.__name__,
283                vendor_id,
284                product_id,
285            )
286            dev_prefs_key = _device_prefs_key(device)
287            dev_prefs = self._prefs[_PREFS_ROOT].setdefault(dev_prefs_key, {})
288            settings = Settings(dev_prefs)
289            self._device_settings[device] = settings
290            self.devices_updated()
291        assert settings is not None
292
293    def _device_added_cb(self, mgr, device):
294        """Informs that a device has been plugged in"""
295        logger.debug("device-added %r", device.get_name())
296        self._init_device_settings(device)
297
298    def _device_removed_cb(self, mgr, device):
299        """Informs that a device has been unplugged"""
300        logger.debug("device-removed %r", device.get_name())
301        self._device_settings.pop(device, None)
302        self.devices_updated()
303
304    @event
305    def devices_updated(self):
306        """Event: the devices list was changed"""
307
308    def get_devices(self):
309        """Yields devices and their settings, for UI stuff
310
311        :rtype: iterator
312        :returns: ultimately a sequence of (Gdk.Device, Settings) pairs
313
314        """
315        for device, settings in self._device_settings.items():
316            yield (device, settings)
317
318    ## Current device
319
320    @event
321    def current_device_changed(self, old_device, new_device):
322        """Event: the current device has changed
323
324        :param Gdk.Device old_device: Previous device used
325        :param Gdk.Device new_device: New device used
326        """
327
328    def device_used(self, device):
329        """Informs about a device being used, for use by controllers
330
331        :param Gdk.Device device: the device being used
332        :returns: whether the device changed
333
334        If the device has changed, this method then notifies interested
335        parties via the device_changed observable @event.
336
337        This method returns True if the device was the same as the previous
338        device, and False if it has changed.
339        """
340        if not self.get_device_settings(device):
341            return False
342        if device == self._last_event_device:
343            return True
344        self.current_device_changed(self._last_event_device, device)
345        old_device = self._last_event_device
346        new_device = device
347        self._last_event_device = device
348
349        # small problem with this code: it doesn't work well with brushes that
350        # have (eraser not in [1.0, 0.0])
351
352        new_device.name = new_device.props.name
353        new_device.source = new_device.props.input_source
354
355        logger.debug(
356            "Device change: name=%r source=%s",
357            new_device.name, new_device.source.value_name,
358        )
359
360        # When editing brush settings, it is often more convenient to use the
361        # mouse. Because of this, we don't restore brushsettings when switching
362        # to/from the mouse. We act as if the mouse was identical to the last
363        # active pen device.
364
365        if (new_device.source == Gdk.InputSource.MOUSE and
366                self._last_pen_device):
367            new_device = self._last_pen_device
368        if new_device.source == Gdk.InputSource.PEN:
369            self._last_pen_device = new_device
370        if (old_device and old_device.source == Gdk.InputSource.MOUSE and
371                self._last_pen_device):
372            old_device = self._last_pen_device
373
374        bm = self._app.brushmanager
375        if old_device:
376            # Clone for saving
377            old_brush = bm.clone_selected_brush(name=None)
378            bm.store_brush_for_device(old_device.name, old_brush)
379
380        if new_device.source == Gdk.InputSource.MOUSE:
381            # Avoid fouling up unrelated devbrushes at stroke end
382            self._prefs.pop('devbrush.last_used', None)
383        else:
384            # Select the brush and update the UI.
385            # Use a sane default if there's nothing associated
386            # with the device yet.
387            brush = bm.fetch_brush_for_device(new_device.name)
388            if brush is None:
389                if device_is_eraser(new_device):
390                    brush = bm.get_default_eraser()
391                else:
392                    brush = bm.get_default_brush()
393            self._prefs['devbrush.last_used'] = new_device.name
394            bm.select_brush(brush)
395
396
397class SettingsEditor (Gtk.Grid):
398    """Per-device settings editor"""
399
400    ## Class consts
401
402    _USAGE_CONFIG_COL = 0
403    _USAGE_STRING_COL = 1
404    _SCROLL_CONFIG_COL = 0
405    _SCROLL_STRING_COL = 1
406
407    __gtype_name__ = "MyPaintDeviceSettingsEditor"
408
409    ## Initialization
410
411    def __init__(self, monitor=None):
412        """Initialize
413
414        :param Monitor monitor: monitor instance (for testing)
415
416        By default, the central app's `device_monitor` is used to permit
417        parameterless construction.
418        """
419        super(SettingsEditor, self).__init__()
420        if monitor is None:
421            app = gui.application.get_app()
422            monitor = app.device_monitor
423        self._monitor = monitor
424
425        self._devices_store = Gtk.ListStore(object)
426        self._devices_view = Gtk.TreeView(model=self._devices_store)
427
428        col = Gtk.TreeViewColumn(C_(
429            "prefs: devices table: column header",
430            # TRANSLATORS: Column's data is the device's name
431            "Device",
432        ))
433        col.set_min_width(200)
434        col.set_expand(True)
435        col.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
436        self._devices_view.append_column(col)
437        cell = Gtk.CellRendererText()
438        cell.set_property("ellipsize", Pango.EllipsizeMode.MIDDLE)
439        col.pack_start(cell, True)
440        col.set_cell_data_func(cell, self._device_name_datafunc)
441
442        col = Gtk.TreeViewColumn(C_(
443            "prefs: devices table: column header",
444            # TRANSLATORS: Column's data is the number of axes (an integer)
445            "Axes",
446        ))
447        col.set_min_width(30)
448        col.set_resizable(True)
449        col.set_expand(False)
450        col.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
451        self._devices_view.append_column(col)
452        cell = Gtk.CellRendererText()
453        col.pack_start(cell, True)
454        col.set_cell_data_func(cell, self._device_axes_datafunc)
455
456        col = Gtk.TreeViewColumn(C_(
457            "prefs: devices table: column header",
458            # TRANSLATORS: Column shows type labels ("Touchscreen", "Pen" etc.)
459            "Type",
460        ))
461        col.set_min_width(120)
462        col.set_resizable(True)
463        col.set_expand(False)
464        col.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
465        self._devices_view.append_column(col)
466        cell = Gtk.CellRendererText()
467        cell.set_property("ellipsize", Pango.EllipsizeMode.END)
468        col.pack_start(cell, True)
469        col.set_cell_data_func(cell, self._device_type_datafunc)
470
471        # Usage config value => string store (dropdowns)
472        store = Gtk.ListStore(str, str)
473        for conf_val in AllowedUsage.VALUES:
474            string = AllowedUsage.DISPLAY_STRING[conf_val]
475            store.append([conf_val, string])
476        self._usage_store = store
477
478        col = Gtk.TreeViewColumn(C_(
479            "prefs: devices table: column header",
480            # TRANSLATORS: Column's data is a dropdown allowing the allowed
481            # TRANSLATORS: tasks for the row's device to be configured.
482            u"Use for…",
483        ))
484        col.set_min_width(100)
485        col.set_resizable(True)
486        col.set_expand(False)
487        self._devices_view.append_column(col)
488
489        cell = Gtk.CellRendererCombo()
490        cell.set_property("model", self._usage_store)
491        cell.set_property("text-column", self._USAGE_STRING_COL)
492        cell.set_property("mode", Gtk.CellRendererMode.EDITABLE)
493        cell.set_property("editable", True)
494        cell.set_property("has-entry", False)
495        cell.set_property("ellipsize", Pango.EllipsizeMode.END)
496        cell.connect("changed", self._usage_cell_changed_cb)
497        col.pack_start(cell, True)
498        col.set_cell_data_func(cell, self._device_usage_datafunc)
499
500        # Scroll action config value => string store (dropdowns)
501        store = Gtk.ListStore(str, str)
502        for conf_val in ScrollAction.VALUES:
503            string = ScrollAction.DISPLAY_STRING[conf_val]
504            store.append([conf_val, string])
505        self._scroll_store = store
506
507        col = Gtk.TreeViewColumn(C_(
508            "prefs: devices table: column header",
509            # TRANSLATORS: Column's data is a dropdown for how the device's
510            # TRANSLATORS: scroll wheel or scroll-gesture events are to be
511            # TRANSLATORS: interpreted normally.
512            u"Scroll…",
513        ))
514        col.set_min_width(100)
515        col.set_resizable(True)
516        col.set_expand(False)
517        self._devices_view.append_column(col)
518
519        cell = Gtk.CellRendererCombo()
520        cell.set_property("model", self._scroll_store)
521        cell.set_property("text-column", self._USAGE_STRING_COL)
522        cell.set_property("mode", Gtk.CellRendererMode.EDITABLE)
523        cell.set_property("editable", True)
524        cell.set_property("has-entry", False)
525        cell.set_property("ellipsize", Pango.EllipsizeMode.END)
526        cell.connect("changed", self._scroll_cell_changed_cb)
527        col.pack_start(cell, True)
528        col.set_cell_data_func(cell, self._device_scroll_datafunc)
529
530        # Pretty borders
531        view_scroll = Gtk.ScrolledWindow()
532        view_scroll.set_shadow_type(Gtk.ShadowType.ETCHED_IN)
533        pol = Gtk.PolicyType.AUTOMATIC
534        view_scroll.set_policy(pol, pol)
535        view_scroll.add(self._devices_view)
536        view_scroll.set_hexpand(True)
537        view_scroll.set_vexpand(True)
538        self.attach(view_scroll, 0, 0, 1, 1)
539
540        self._update_devices_store()
541        self._monitor.devices_updated += self._update_devices_store
542
543    ## Display and sort funcs
544
545    def _device_name_datafunc(self, column, cell, model, iter_, *data):
546        device = model.get_value(iter_, 0)
547        cell.set_property("text", device.get_name())
548
549    def _device_axes_datafunc(self, column, cell, model, iter_, *data):
550        device = model.get_value(iter_, 0)
551        n_axes = device.get_n_axes()
552        cell.set_property("text", "%d" % (n_axes,))
553
554    def _device_type_datafunc(self, column, cell, model, iter_, *data):
555        device = model.get_value(iter_, 0)
556        source = device.get_source()
557        text = _DEVICE_TYPE_STRING.get(source, source.value_nick)
558        cell.set_property("text", text)
559
560    def _device_usage_datafunc(self, column, cell, model, iter_, *data):
561        device = model.get_value(iter_, 0)
562        settings = self._monitor.get_device_settings(device)
563        if not settings:
564            return
565        text = AllowedUsage.DISPLAY_STRING[settings.usage]
566        cell.set_property("text", text)
567
568    def _device_scroll_datafunc(self, column, cell, model, iter_, *data):
569        device = model.get_value(iter_, 0)
570        settings = self._monitor.get_device_settings(device)
571        if not settings:
572            return
573        text = ScrollAction.DISPLAY_STRING[settings.scroll]
574        cell.set_property("text", text)
575
576    ## Updates
577
578    def _usage_cell_changed_cb(self, combo, device_path_str,
579                               usage_iter, *etc):
580        config = self._usage_store.get_value(
581            usage_iter,
582            self._USAGE_CONFIG_COL,
583        )
584        device_iter = self._devices_store.get_iter(device_path_str)
585        device = self._devices_store.get_value(device_iter, 0)
586        settings = self._monitor.get_device_settings(device)
587        if not settings:
588            return
589        settings.usage = config
590        self._devices_view.columns_autosize()
591
592    def _scroll_cell_changed_cb(self, conf_combo, device_path_str,
593                                conf_iter, *etc):
594        conf_store = self._scroll_store
595        conf_col = self._SCROLL_CONFIG_COL
596        conf_value = conf_store.get_value(conf_iter, conf_col)
597        device_store = self._devices_store
598        device_iter = device_store.get_iter(device_path_str)
599        device = device_store.get_value(device_iter, 0)
600        settings = self._monitor.get_device_settings(device)
601        if not settings:
602            return
603        settings.scroll = conf_value
604        self._devices_view.columns_autosize()
605
606    def _update_devices_store(self, *_ignored):
607        """Repopulates the displayed list"""
608        updated_list = list(self._monitor.get_devices())
609        updated_list_map = dict(updated_list)
610        paths_for_removal = []
611        devices_retained = set()
612        for row in self._devices_store:
613            device, = row
614            if device not in updated_list_map:
615                paths_for_removal.append(row.path)
616                continue
617            devices_retained.add(device)
618        for device, config in updated_list:
619            if device in devices_retained:
620                continue
621            self._devices_store.append([device])
622        for unwanted_row_path in reversed(paths_for_removal):
623            unwanted_row_iter = self._devices_store.get_iter(unwanted_row_path)
624            self._devices_store.remove(unwanted_row_iter)
625        self._devices_view.queue_draw()
626
627
628## Helper funcs
629
630
631def _device_prefs_key(device):
632    """Returns the subkey to use in the app prefs for a device"""
633    source = device.get_source()
634    name = device.get_name()
635    n_axes = device.get_n_axes()
636    return u"%s:%s:%d" % (name, source.value_nick, n_axes)
637
638
639def device_is_eraser(device):
640    """Tests whether a device appears to be an eraser"""
641    if device is None:
642        return False
643    if device.get_source() == Gdk.InputSource.ERASER:
644        return True
645    if re.search(r'\<eraser\>', device.get_name(), re.I):
646        return True
647    return False
648
649
650## Testing
651
652
653def _test():
654    """Interactive UI testing for SettingsEditor and Monitor"""
655    logging.basicConfig(level=logging.DEBUG)
656    win = Gtk.Window()
657    win.set_title("gui.device.SettingsEditor")
658    win.set_default_size(500, 400)
659    win.connect("destroy", Gtk.main_quit)
660    monitor = Monitor(app=None)
661    editor = SettingsEditor(monitor)
662    win.add(editor)
663    win.show_all()
664    Gtk.main()
665    print(monitor._prefs)
666
667
668if __name__ == '__main__':
669    _test()
670