Improve button mapping, spring-loaded modes
[mypaint:maxy-experimental.git] / gui / application.py
1 # This file is part of MyPaint.
2 # Copyright (C) 2007 by Martin Renold <martinxyz@gmx.ch>
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 import os, sys
10 from os.path import join
11 import gtk, gobject
12 gdk = gtk.gdk
13 from lib import brush, helpers, mypaintlib
14 import filehandling, keyboard, brushmanager, windowing, document, layout
15 import brushmodifier, linemode
16 import colors
17 from colorwindow import BrushColorManager
18 from overlays import LastPaintPosOverlay, ScaleOverlay
19 from buttonmap import ButtonMapping
20
21 import pygtkcompat
22
23 class Application: # singleton
24     """
25     This class serves as a global container for everything that needs
26     to be shared in the GUI. Its constructor is the last part of the
27     initialization, called by main.py or by the testing scripts.
28     """
29
30     def __init__(self, datapath, extradata, confpath, filenames):
31         """Construct, but do not run.
32
33         :`datapath`:
34             Usually ``$PREFIX/share/mypaint``. Where MyPaint should find its
35             app-specific read-only data, e.g. UI definition XML, backgrounds
36             and brush defintions.
37         :`extradata`:
38             Where to find the defaults for MyPaint's themeable UI icons. This
39             will be effectively used in addition to ``$XDG_DATA_DIRS`` for the
40             purposes of icon lookup. Normally it's ``$PREFIX/share``, to support
41             unusual installations outside the usual locations. It should contain
42             an ``icons/`` subdirectory.
43         :`confpath`:
44             Where the user's configuration is stored. ``$HOME/.mypaint`` is
45             typical on Unix-like OSes.
46         """
47         self.confpath = confpath
48         self.datapath = datapath
49
50         # create config directory, and subdirs where the user might drop files
51         # TODO make scratchpad dir something pulled from preferences #PALETTE1
52         for d in ['', 'backgrounds', 'brushes', 'scratchpads']:
53             d = os.path.join(self.confpath, d)
54             if not os.path.isdir(d):
55                 os.mkdir(d)
56                 print 'Created', d
57
58         # Default location for our icons. The user's theme can override these.
59         icon_theme = gtk.icon_theme_get_default()
60         icon_theme.append_search_path(join(extradata, "icons"))
61
62         # Icon sanity check
63         if not icon_theme.has_icon('mypaint') \
64                 or not icon_theme.has_icon('mypaint-tool-brush'):
65             print 'Error: Where have my icons gone?'
66             print 'Icon search path:', icon_theme.get_search_path()
67             print "Mypaint can't run sensibly without its icons; " \
68                 + "please check your installation."
69             print 'see https://gna.org/bugs/?18460 for possible solutions'
70             sys.exit(1)
71
72         if pygtkcompat.USE_GTK3:
73             gtk.Window.set_default_icon_name('mypaint')
74         else:
75             gtk.window_set_default_icon_name('mypaint')
76
77         # Stock items, core actions, and menu structure
78         builder_xml = join(datapath, "gui", "mypaint.xml")
79         self.builder = gtk.Builder()
80         self.builder.set_translation_domain("mypaint")
81         self.builder.add_from_file(builder_xml)
82         factory = self.builder.get_object("stock_icon_factory")
83         factory.add_default()
84
85         self.ui_manager = self.builder.get_object("app_ui_manager")
86         signal_callback_objs = []
87
88         gdk.set_program_class('MyPaint')
89
90         self.pixmaps = PixbufDirectory(join(self.datapath, 'pixmaps'))
91         self.cursor_color_picker = gdk.Cursor(
92                   pygtkcompat.gdk.display_get_default(),
93                   self.pixmaps.cursor_color_picker,
94                   1, 30)
95
96         # unmanaged main brush; always the same instance (we can attach settings_observers)
97         # this brush is where temporary changes (color, size...) happen
98         self.brush = brush.BrushInfo()
99         self.brush.load_defaults()
100
101         # Global pressure mapping function, ignored unless set
102         self.pressure_mapping = None
103
104         self.preferences = {}
105         self.load_settings()
106
107         self.scratchpad_filename = ""
108         self.kbm = keyboard.KeyboardManager(self)
109         self.doc = document.Document(self)
110         signal_callback_objs.append(self.doc)
111         signal_callback_objs.append(self.doc.modes)
112         self.scratchpad_doc = document.Document(self, leader=self.doc)
113         self.brushmanager = brushmanager.BrushManager(join(datapath, 'brushes'), join(confpath, 'brushes'), self)
114         self.filehandler = filehandling.FileHandler(self)
115         signal_callback_objs.append(self.filehandler)
116         self.brushmodifier = brushmodifier.BrushModifier(self)
117         self.line_mode_settings = linemode.LineModeSettings(self)
118
119         # Button press mapping
120         self.button_mapping = ButtonMapping()
121
122         # Monitors changes of input device & saves device-specific brushes
123         self.device_monitor = DeviceUseMonitor(self)
124
125         if not self.preferences.get("scratchpad.last_opened_scratchpad", None):
126             self.preferences["scratchpad.last_opened_scratchpad"] = self.filehandler.get_scratchpad_autosave()
127         self.scratchpad_filename = self.preferences["scratchpad.last_opened_scratchpad"]
128
129         self.brush_color_manager = BrushColorManager(self)
130         self.brush_color_manager.set_picker_cursor(self.cursor_color_picker)
131         self.brush_color_manager.set_data_path(datapath)
132
133         self.init_brush_adjustments()
134
135         self.layout_manager = layout.LayoutManager(
136             prefs=self.preferences["layout.window_positions"],
137             factory=windowing.window_factory,
138             factory_opts=[self]  )
139         self.drawWindow = self.layout_manager.get_widget_by_role("main-window")
140         self.layout_manager.show_all()
141
142         signal_callback_objs.append(self.drawWindow)
143
144         # Connect signals defined in mypaint.xml
145         callback_finder = CallbackFinder(signal_callback_objs)
146         self.builder.connect_signals(callback_finder)
147
148         self.kbm.start_listening()
149         self.filehandler.doc = self.doc
150         self.filehandler.filename = None
151         pygtkcompat.gtk.accel_map_load(join(self.confpath, 'accelmap.conf'))
152
153         # Load the background settings window.
154         # FIXME: this line shouldn't be needed, but we need to load this up
155         # front to get any non-default background that the user has configured
156         # from the preferences.
157         self.layout_manager.get_subwindow_by_role("backgroundWindow")
158
159         # And the brush settings window, or things like eraser mode will break.
160         # FIXME: brush_adjustments should not be dependent on this
161         self.layout_manager.get_subwindow_by_role("brushSettingsWindow")
162
163         def at_application_start(*junk):
164             col = self.brush_color_manager.get_color()
165             self.brushmanager.select_initial_brush()
166             self.brush_color_manager.set_color(col)
167             if filenames:
168                 # Open only the first file, no matter how many has been specified
169                 # If the file does not exist just set it as the file to save to
170                 fn = filenames[0].replace('file:///', '/') # some filebrowsers do this (should only happen with outdated mypaint.desktop)
171                 if not os.path.exists(fn):
172                     self.filehandler.filename = fn
173                 else:
174                     self.filehandler.open_file(fn)
175
176             # Load last scratchpad
177             if not self.preferences["scratchpad.last_opened_scratchpad"]:
178                 self.preferences["scratchpad.last_opened_scratchpad"] = self.filehandler.get_scratchpad_autosave()
179                 self.scratchpad_filename = self.preferences["scratchpad.last_opened_scratchpad"]
180             if os.path.isfile(self.scratchpad_filename):
181                 try:
182                     self.filehandler.open_scratchpad(self.scratchpad_filename)
183                 except AttributeError, e:
184                     print "Scratchpad widget isn't initialised yet, so cannot centre"
185
186
187             self.apply_settings()
188             if not self.pressure_devices:
189                 print 'No pressure sensitive devices found.'
190             self.drawWindow.present()
191
192         gobject.idle_add(at_application_start)
193
194     def save_settings(self):
195         """Saves the current settings to persistent storage."""
196         def save_config():
197             settingspath = join(self.confpath, 'settings.json')
198             jsonstr = helpers.json_dumps(self.preferences)
199             f = open(settingspath, 'w')
200             f.write(jsonstr)
201             f.close()
202         self.brushmanager.save_brushes_for_devices()
203         self.brushmanager.save_brush_history()
204         self.filehandler.save_scratchpad(self.scratchpad_filename)
205         save_config()
206
207     def apply_settings(self):
208         """Applies the current settings."""
209         self.update_input_mapping()
210         self.update_input_devices()
211         self.update_button_mapping()
212         prefs_win = self.layout_manager.get_widget_by_role('preferencesWindow')
213         prefs_win.update_ui()
214
215     def load_settings(self):
216         '''Loads the settings from persistent storage. Uses defaults if
217         not explicitly configured'''
218         def get_legacy_config():
219             dummyobj = {}
220             tmpdict = {}
221             settingspath = join(self.confpath, 'settings.conf')
222             if os.path.exists(settingspath):
223                 exec open(settingspath) in dummyobj
224                 tmpdict['saving.scrap_prefix'] = dummyobj['save_scrap_prefix']
225                 tmpdict['input.device_mode'] = dummyobj['input_devices_mode']
226                 tmpdict['input.global_pressure_mapping'] = dummyobj['global_pressure_mapping']
227             return tmpdict
228         def get_json_config():
229             settingspath = join(self.confpath, 'settings.json')
230             jsonstr = open(settingspath).read()
231             try:
232                 return helpers.json_loads(jsonstr)
233             except Exception, e:
234                 print "settings.json: %s" % (str(e),)
235                 print "warning: failed to load settings.json, using defaults"
236                 return {}
237         if sys.platform == 'win32':
238             import glib
239             scrappre = join(glib.get_user_special_dir(glib.USER_DIRECTORY_DOCUMENTS).decode('utf-8'),'MyPaint','scrap')
240         else:
241             scrappre = '~/MyPaint/scrap'
242         DEFAULT_CONFIG = {
243             'saving.scrap_prefix': scrappre,
244             'input.device_mode': 'screen',
245             'input.global_pressure_mapping': [(0.0, 1.0), (1.0, 0.0)],
246             'view.default_zoom': 1.0,
247             'view.high_quality_zoom': True,
248             'ui.hide_menubar_in_fullscreen': True,
249             'ui.hide_toolbar_in_fullscreen': True,
250             'ui.hide_subwindows_in_fullscreen': True,
251             'ui.parts': dict(main_toolbar=True, menubar=False),
252             'ui.feedback.scale': True,
253             'ui.feedback.last_pos': False,
254             'ui.toolbar_items': dict(
255                 toolbar1_file=False,
256                 toolbar1_scrap=False,
257                 toolbar1_edit=True,
258                 toolbar1_editmodes=True,
259                 toolbar1_blendmodes=False,
260                 toolbar1_view=False,
261                 toolbar1_subwindows=True,
262             ),
263             'saving.default_format': 'openraster',
264             'brushmanager.selected_brush' : None,
265             'brushmanager.selected_groups' : [],
266             'frame.color_rgba': (0.12, 0.12, 0.12, 0.92),
267             'misc.context_restores_color': True,
268
269             "scratchpad.last_opened_scratchpad": "",
270
271             # Default window positions.
272             # See gui.layout.set_window_initial_position for the meanings
273             # of the common x, y, w, and h settings
274             "layout.window_positions": {
275
276                 # Main window default size. Sidebar width is saved here
277                 'main-window': dict(sbwidth=250, x=50, y=32, w=-50, h=-100),
278
279                 # Tool windows. These can be undocked (floating=True) or set
280                 # initially hidden (hidden=True), or be given an initial sidebar
281                 # index (sbindex=<int>) or height in the sidebar (sbheight=<int>)
282                 # Non-hidden entries determine the default set of tools.
283                 'brushSelectionWindow': dict(
284                         sbindex=2, floating=True, hidden=True,
285                         x=-100, y=-150, w=250, h=350, sbheight=350),
286                 'layersWindow': dict(
287                         sbindex=3, floating=True, hidden=True,
288                         x=-460, y=-150, w=200, h=200, sbheight=200),
289                 'scratchWindow': dict(
290                         sbindex=4, floating=True, hidden=True,
291                         x=-555, y=125, w=300, h=250, sbheight=250),
292                 'colorWindow': dict(
293                         sbindex=0, floating=True, hidden=True,
294                         x=-100, y=125, w=250, h=300, sbheight=300),
295
296                 # Non-tool subwindows. These cannot be docked, and are all
297                 # intially hidden.
298                 'brushSettingsWindow': dict(x=-460, y=-128, w=300, h=300),
299                 'backgroundWindow': dict(),
300                 'inputTestWindow': dict(),
301                 'frameWindow': dict(),
302                 'preferencesWindow': dict(),
303             },
304             # Linux defaults.
305             # Alt is the normal window resizing/moving key these days,
306             # so provide a Ctrl-based equivalent for all alt actions.
307             'input.button_mapping': {
308                 # Note that space is treated as a fake Button2
309                 '<Shift>Button1':          'StraightMode',
310                 '<Control>Button1':        'ColorPickerPopup',
311                 '<Alt>Button1':            'ColorPickerPopup',
312                 'Button2':                 'PanViewMode',
313                 '<Shift>Button2':          'RotateViewMode',
314                 '<Control>Button2':        'ZoomViewMode',
315                 '<Alt>Button2':            'ZoomViewMode',
316                 '<Control><Shift>Button2': 'FrameEditMode',
317                 '<Alt><Shift>Button2':     'FrameEditMode',
318                 'Button3':                 'ShowPopupMenu',
319             },
320         }
321         if sys.platform == 'win32':
322             # The Linux wacom driver inverts the button numbers of the
323             # pen flip button, because middle-click is the more useful
324             # action on Linux. However one of the two buttons is often
325             # accidentally hit with the thumb while painting. We want
326             # to assign panning to this button by default.
327             linux_mapping = DEFAULT_CONFIG["input.button_mapping"]
328             DEFAULT_CONFIG["input.button_mapping"] = {}
329             for bp, actname in linux_mapping.iteritems():
330                 bp = bp.replace("Button2", "ButtonTMP")
331                 bp = bp.replace("Button3", "Button2")
332                 bp = bp.replace("ButtonTMP", "Button3")
333                 DEFAULT_CONFIG["input.button_mapping"][bp] = actname
334
335
336         window_pos = DEFAULT_CONFIG["layout.window_positions"]
337         self.window_names = window_pos.keys()
338         self.preferences = DEFAULT_CONFIG
339         try:
340             user_config = get_json_config()
341         except IOError:
342             user_config = get_legacy_config()
343         user_window_pos = user_config.get("layout.window_positions", {})
344         # note: .update() replaces the window position dict, but we want to update it
345         self.preferences.update(user_config)
346         # update window_pos, and drop window names that don't exist any more
347         # (we need to drop them because otherwise we will try to show a non-existing window)
348         for role in self.window_names:
349             if role in user_window_pos:
350                 window_pos[role] = user_window_pos[role]
351         self.preferences["layout.window_positions"] = window_pos
352
353     def add_action_group(self, ag):
354         self.ui_manager.insert_action_group(ag, -1)
355
356     def find_action(self, name):
357         for ag in self.ui_manager.get_action_groups():
358             result = ag.get_action(name)
359             if result is not None:
360                 return result
361
362     def init_brush_adjustments(self):
363         """Initializes all the brush adjustments for the current brush"""
364         self.brush_adjustment = {}
365         from brushlib import brushsettings
366         for i, s in enumerate(brushsettings.settings_visible):
367             adj = gtk.Adjustment(value=s.default, lower=s.min, upper=s.max, step_incr=0.01, page_incr=0.1)
368             self.brush_adjustment[s.cname] = adj
369
370     def update_button_mapping(self):
371         self.button_mapping.update(self.preferences["input.button_mapping"])
372
373     def update_input_mapping(self):
374         p = self.preferences['input.global_pressure_mapping']
375         if len(p) == 2 and abs(p[0][1]-1.0)+abs(p[1][1]-0.0) < 0.0001:
376             # 1:1 mapping (mapping disabled)
377             self.pressure_mapping = None
378         else:
379             # TODO: maybe replace this stupid mapping by a hard<-->soft slider?
380             #       But then we would also need a "minimum pressure" setting,
381             #       or else this often used workaround is no longer possible:
382             #       http://wiki.mypaint.info/File:Pressure_workaround.png
383             m = mypaintlib.MappingWrapper(1)
384             m.set_n(0, len(p))
385             for i, (x, y) in enumerate(p):
386                 m.set_point(0, i, x, 1.0-y)
387
388             def mapping(pressure):
389                 return m.calculate_single_input(pressure)
390             self.pressure_mapping = mapping
391
392     def update_input_devices(self):
393         # avoid doing this 5 times at startup
394         modesetting = self.preferences['input.device_mode']
395         if getattr(self, 'last_modesetting', None) == modesetting:
396             return
397         self.last_modesetting = modesetting
398
399         # init extended input devices
400         self.pressure_devices = []
401
402         if pygtkcompat.USE_GTK3:
403             display = pygtkcompat.gdk.display_get_default()
404             device_mgr = display.get_device_manager()
405             for device in device_mgr.list_devices(gdk.DeviceType.SLAVE):
406                 if device.get_source() == gdk.InputSource.KEYBOARD:
407                     continue
408                 name = device.get_name().lower()
409                 n_axes = device.get_n_axes()
410                 if n_axes <= 0:
411                     continue
412                 # TODO: may need exception voodoo, min/max checking etc. here
413                 #       like the GTK2 code below.
414                 for i in xrange(n_axes):
415                     use = device.get_axis_use(i)
416                     if use != gdk.AxisUse.PRESSURE:
417                         continue
418                     # Set preferred device mode
419                     mode = getattr(gdk.InputMode, modesetting.upper())
420                     if device.get_mode() != mode:
421                         print 'Setting %s mode for "%s"' \
422                           % (mode, device.get_name())
423                         device.set_mode(mode)
424                     # Record as a pressure-sensitive device
425                     self.pressure_devices.append(name)
426                     break
427             return
428
429         # GTK2/PyGTK
430         print 'Looking for GTK devices with pressure:'
431         for device in gdk.devices_list():
432             #print device.name, device.source
433
434             #if device.source in [gdk.SOURCE_PEN, gdk.SOURCE_ERASER]:
435             # The above contition is True sometimes for a normal USB
436             # Mouse. https://gna.org/bugs/?11215
437             # In fact, GTK also just guesses this value from device.name.
438
439             #print 'Device "%s" (%s) reports %d axes.' % (device.name, device.source.value_name, len(device.axes))
440
441             pressure = False
442             for use, val_min, val_max in device.axes:
443                 if use == gdk.AXIS_PRESSURE:
444                     print 'Device "%s" has a pressure axis' % device.name
445                     # Some mice have a third "pressure" axis, but without minimum or maximum.
446                     if val_min == val_max:
447                         print 'But the pressure range is invalid'
448                     else:
449                         pressure = True
450                     break
451             if not pressure:
452                 #print 'Skipping device "%s" because it has no pressure axis' % device.name
453                 continue
454
455             name = device.name.lower()
456             name = name.replace('-', ' ').replace('_', ' ')
457             last_word = name.split()[-1]
458
459             # Step 1: BLACKLIST
460             if last_word == 'pad':
461                 # Setting the intuos3 pad into "screen mode" causes
462                 # glitches when you press a pad-button in mid-stroke,
463                 # and it's not a pointer device anyway. But it reports
464                 # axes almost identical to the pen and eraser.
465                 #
466                 # device.name is usually something like "wacom intuos3 6x8 pad" or just "pad"
467                 print 'Skipping "%s" (probably wacom keypad device)' % device.name
468                 continue
469             if last_word == 'touchpad':
470                 print 'Skipping "%s" (probably a laptop touchpad without pressure info)' % device.name
471                 continue
472             if last_word == 'cursor':
473                 # for wacom, this is the "normal" mouse and does not work in screen mode
474                 print 'Skipping "%s" (probably wacom mouse device)' % device.name
475                 continue
476             if 'keyboard' in name:
477                 print 'Skipping "%s" (probably a keyboard)' % device.name
478                 continue
479             if 'mouse' in name and 'mousepen' not in name:
480                 print 'Skipping "%s" (probably a mouse)' % device.name
481                 continue
482
483             # Step 2: WHITELIST
484             #
485             # Required now as too many input devices report a pressure
486             # axis with recent Xorg versions. Wrongly enabling them
487             # breaks keyboard and/or mouse input in random ways.
488             #
489             # Only whole words are matched.
490             tablet_strings  = '''
491             tablet pressure graphic art pen stylus eraser pencil brush
492             wacom bamboo intuos graphire cintiq
493             hanvon rollick graphicpal artmaster sentip
494             genius mousepen
495             aiptek
496             '''
497             match = False
498             words = name.split()
499             for s in tablet_strings.split():
500                 if s in words:
501                     match = True
502             if not match:
503                 print 'Skipping "%s" (not in the list of known tablets)' % device.name
504                 continue
505
506             self.pressure_devices.append(device.name)
507             mode = getattr(gdk, 'MODE_' + modesetting.upper())
508             if device.mode != mode:
509                 print 'Setting %s mode for "%s"' % (modesetting, device.name)
510                 device.set_mode(mode)
511         print ''
512
513     def save_gui_config(self):
514         pygtkcompat.gtk.accel_map_save(join(self.confpath, 'accelmap.conf'))
515         self.save_settings()
516
517     def message_dialog(self, text, type=gtk.MESSAGE_INFO, flags=0,
518                        secondary_text=None, long_text=None, title=None):
519         """Utility function to show a message/information dialog.
520         """
521         d = gtk.MessageDialog(self.drawWindow, flags=flags, type=type,
522                               buttons=gtk.BUTTONS_OK)
523         d.set_markup(text)
524         if title is not None:
525             d.set_title(title)
526         if secondary_text is not None:
527             d.format_secondary_markup(secondary_text)
528         if long_text is not None:
529             buf = gtk.TextBuffer()
530             buf.set_text(long_text)
531             tv = gtk.TextView(buf)
532             tv.show()
533             tv.set_editable(False)
534             tv.set_wrap_mode(gtk.WRAP_WORD_CHAR)
535             scrolls = gtk.ScrolledWindow()
536             scrolls.show()
537             scrolls.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_ALWAYS)
538             scrolls.add(tv)
539             scrolls.set_size_request(-1, 300)
540             scrolls.set_shadow_type(gtk.SHADOW_IN)
541             d.get_message_area().pack_start(scrolls)
542         d.run()
543         d.destroy()
544
545     def pick_color_at_pointer(self, widget, size=3):
546         """Set the brush colour from the current pointer position on screen.
547
548         This is a wrapper for `gui.colors.get_color_at_pointer()`, and
549         additionally sets the current brush colour.
550
551         """
552         color = colors.get_color_at_pointer(widget, size)
553         self.brush_color_manager.set_color(color)
554
555
556 class DeviceUseMonitor (object):
557     """Monitors device uses and detects changes.
558     """
559
560     def __init__(self, app):
561         """Initialize.
562
563         :param app: the main Application singleton.
564         """
565         object.__init__(self)
566         self.app = app
567         self.device_observers = []   #: See `device_used()`.
568         self._last_event_device = None
569         self._last_pen_device = None
570         self.device_observers.append(self.device_changed_cb)
571
572
573     def device_used(self, device):
574         """Notify about a device being used; for use by controllers etc.
575
576         :param device: the device being used
577
578         If the device has changed, this method then notifies the registered
579         observers via callbacks in device_observers. Callbacks are invoked as
580
581             callback(old_device, new_device)
582
583         This method returns True if the device was the same as the previous
584         device, and False if it has changed.
585
586         """
587         if device == self._last_event_device:
588             return True
589         for func in self.device_observers:
590             func(self._last_event_device, device)
591         self._last_event_device = device
592         return False
593
594
595     def device_is_eraser(self, device):
596         if device is None:
597             return False
598         return device.source == gdk.SOURCE_ERASER \
599                 or 'eraser' in device.name.lower()
600
601
602     def device_changed_cb(self, old_device, new_device):
603         # small problem with this code: it doesn't work well with brushes that
604         # have (eraser not in [1.0, 0.0])
605
606         if pygtkcompat.USE_GTK3:
607             new_device.name = new_device.props.name
608             new_device.source = new_device.props.input_source
609
610         print 'device change:', new_device.name, new_device.source
611
612         # When editing brush settings, it is often more convenient to use the
613         # mouse. Because of this, we don't restore brushsettings when switching
614         # to/from the mouse. We act as if the mouse was identical to the last
615         # active pen device.
616
617         if new_device.source == gdk.SOURCE_MOUSE and self._last_pen_device:
618             new_device = self._last_pen_device
619         if new_device.source == gdk.SOURCE_PEN:
620             self._last_pen_device = new_device
621         if old_device and old_device.source == gdk.SOURCE_MOUSE \
622                     and self._last_pen_device:
623             old_device = self._last_pen_device
624
625         bm = self.app.brushmanager
626         if old_device:
627             # Clone for saving
628             old_brush = bm.clone_selected_brush(name=None)
629             bm.store_brush_for_device(old_device.name, old_brush)
630
631         if new_device.source == gdk.SOURCE_MOUSE:
632             # Avoid fouling up unrelated devbrushes at stroke end
633             self.app.preferences.pop('devbrush.last_used', None)
634         else:
635             # Select the brush and update the UI.
636             # Use a sane default if there's nothing associated
637             # with the device yet.
638             brush = bm.fetch_brush_for_device(new_device.name)
639             if brush is None:
640                 if self.device_is_eraser(new_device):
641                     brush = bm.get_default_eraser()
642                 else:
643                     brush = bm.get_default_brush()
644             self.app.preferences['devbrush.last_used'] = new_device.name
645             bm.select_brush(brush)
646
647
648 class PixbufDirectory:
649     def __init__(self, dirname):
650         self.dirname = dirname
651         self.cache = {}
652
653     def __getattr__(self, name):
654         if name not in self.cache:
655             try:
656                 pixbuf = gdk.pixbuf_new_from_file(join(self.dirname, name + '.png'))
657             except gobject.GError, e:
658                 raise AttributeError, str(e)
659             self.cache[name] = pixbuf
660         return self.cache[name]
661
662
663 class CallbackFinder:
664     """Finds callbacks amongst a list of objects.
665
666     It's not possible to call `GtkBuilder.connect_signals()` more than once,
667     but we use more tnan one backend object. Thus, this little workaround is
668     necessary during construction.
669
670     See http://stackoverflow.com/questions/4637792
671
672     """
673
674     def __init__(self, objects):
675         self._objs = list(objects)
676
677     def __getitem__(self, name):
678         # PyGTK/GTK2 uses getitem
679         name = str(name)
680         found = [getattr(obj, name) for obj in self._objs
681                   if hasattr(obj, name)]
682         if len(found) == 1:
683             return found[0]
684         elif len(found) > 1:
685             print "WARNING: ambiguity: %r resolves to %r" % (name, found)
686             print "WARNING: using first match only."
687             return found[0]
688         else:
689             raise AttributeError, "No method named %r was defined " \
690                 "on any of %r" % (name, self._objs)
691
692     # PyGI/GTK3's override uses getattr()
693     __getattr__ = __getitem__
694