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