feedback/overlays: begin categorizing, add toggles
[mypaint:mypaint.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 colorhistory, brushmodifier
16 import stock
17 from overlays import LastPaintPosOverlay, ScaleOverlay
18
19
20 class Application: # singleton
21     """
22     This class serves as a global container for everything that needs
23     to be shared in the GUI. Its constructor is the last part of the
24     initialization, called by main.py or by the testing scripts.
25     """
26     def __init__(self, datapath, extradata, confpath, filenames):
27         """Construct, but do not run.
28
29         :`datapath`:
30             Usually ``$PREFIX/share/mypaint``. Where MyPaint should find its
31             app-specific read-only data, e.g. UI definition XML, backgrounds
32             and brush defintions.
33         :`extradata`:
34             Where to find the defaults for MyPaint's themeable UI icons. This
35             will be effectively used in addition to ``$XDG_DATA_DIRS`` for the
36             purposes of icon lookup. Normally it's ``$PREFIX/share``, to support
37             unusual installations outside the usual locations. It should contain
38             an ``icons/`` subdirectory.
39         :`confpath`:
40             Where the user's configuration is stored. ``$HOME/.mypaint`` is
41             typical on Unix-like OSes.
42         """
43         self.confpath = confpath
44         self.datapath = datapath
45
46         # create config directory, and subdirs where the user might drop files
47         # TODO make scratchpad dir something pulled from preferences #PALETTE1
48         for d in ['', 'backgrounds', 'brushes', 'scratchpads']:
49             d = os.path.join(self.confpath, d)
50             if not os.path.isdir(d):
51                 os.mkdir(d)
52                 print 'Created', d
53
54         # Default location for our icons. The user's theme can override these.
55         icon_theme = gtk.icon_theme_get_default()
56         icon_theme.append_search_path(join(extradata, "icons"))
57
58         # Icon sanity check
59         if not icon_theme.has_icon('mypaint') \
60                 or not icon_theme.has_icon('mypaint-tool-brush'):
61             print 'Error: Where have my icons gone?'
62             print 'Icon search path:', icon_theme.get_search_path()
63             print "Mypaint can't run sensibly without its icons; " \
64                 + "please check your installation."
65             print 'see https://gna.org/bugs/?18460 for possible solutions'
66             sys.exit(1)
67         gtk.window_set_default_icon_name('mypaint')
68
69         stock.init_custom_stock_items()
70
71         self.ui_manager = gtk.UIManager()
72
73         gdk.set_program_class('MyPaint')
74
75         self.pixmaps = PixbufDirectory(join(self.datapath, 'pixmaps'))
76         self.cursor_color_picker = gdk.Cursor(gdk.display_get_default(), self.pixmaps.cursor_color_picker, 1, 30)
77
78         # unmanaged main brush; always the same instance (we can attach settings_observers)
79         # this brush is where temporary changes (color, size...) happen
80         self.brush = brush.BrushInfo()
81         self.brush.load_defaults()
82
83         self.preferences = {}
84         self.load_settings()
85
86         self.scratchpad_filename = ""
87         self.kbm = keyboard.KeyboardManager()
88         self.doc = document.Document(self)
89         self.scratchpad_doc = document.Document(self, leader=self.doc)
90         self.brushmanager = brushmanager.BrushManager(join(datapath, 'brushes'), join(confpath, 'brushes'), self)
91         self.filehandler = filehandling.FileHandler(self)
92         self.brushmodifier = brushmodifier.BrushModifier(self)
93
94         if not self.preferences.get("scratchpad.last_opened_scratchpad", None):
95             self.preferences["scratchpad.last_opened_scratchpad"] = self.filehandler.get_scratchpad_autosave()
96         self.scratchpad_filename = self.preferences["scratchpad.last_opened_scratchpad"]
97
98         self.brush.set_color_hsv((0, 0, 0))
99         self.init_brush_adjustments()
100
101         self.ch = colorhistory.ColorHistory(self)
102
103         self.layout_manager = layout.LayoutManager(
104             prefs=self.preferences["layout.window_positions"],
105             factory=windowing.window_factory,
106             factory_opts=[self]  )
107         self.drawWindow = self.layout_manager.get_widget_by_role("main-window")
108         self.layout_manager.show_all()
109
110         self.kbm.start_listening()
111         self.filehandler.doc = self.doc
112         self.filehandler.filename = None
113         gtk.accel_map_load(join(self.confpath, 'accelmap.conf'))
114
115         # Load the background settings window.
116         # FIXME: this line shouldn't be needed, but we need to load this up
117         # front to get any non-default background that the user has configured
118         # from the preferences.
119         self.layout_manager.get_subwindow_by_role("backgroundWindow")
120
121         # And the brush settings window, or things like eraser mode will break.
122         # FIXME: brush_adjustments should not be dependent on this
123         self.layout_manager.get_subwindow_by_role("brushSettingsWindow")
124
125         def at_application_start(*junk):
126             self.brushmanager.select_initial_brush()
127             if filenames:
128                 # Open only the first file, no matter how many has been specified
129                 # If the file does not exist just set it as the file to save to
130                 fn = filenames[0].replace('file:///', '/') # some filebrowsers do this (should only happen with outdated mypaint.desktop)
131                 if not os.path.exists(fn):
132                     self.filehandler.filename = fn
133                 else:
134                     self.filehandler.open_file(fn)
135
136             # Load last scratchpad
137             if not self.preferences["scratchpad.last_opened_scratchpad"]:
138                 self.preferences["scratchpad.last_opened_scratchpad"] = self.filehandler.get_scratchpad_autosave()
139                 self.scratchpad_filename = self.preferences["scratchpad.last_opened_scratchpad"]
140             if os.path.isfile(self.scratchpad_filename):
141                 try:
142                     self.filehandler.open_scratchpad(self.scratchpad_filename)
143                 except AttributeError, e:
144                     print "Scratchpad widget isn't initialised yet, so cannot centre"
145
146
147             self.apply_settings()
148             if not self.pressure_devices:
149                 print 'No pressure sensitive devices found.'
150             self.drawWindow.present()
151
152         gobject.idle_add(at_application_start)
153
154     def save_settings(self):
155         """Saves the current settings to persistent storage."""
156         def save_config():
157             settingspath = join(self.confpath, 'settings.json')
158             jsonstr = helpers.json_dumps(self.preferences)
159             f = open(settingspath, 'w')
160             f.write(jsonstr)
161             f.close()
162         self.brushmanager.save_brushes_for_devices()
163         self.brushmanager.save_brush_history()
164         self.filehandler.save_scratchpad(self.scratchpad_filename)
165         save_config()
166
167     def apply_settings(self):
168         """Applies the current settings."""
169         self.update_input_mapping()
170         self.update_input_devices()
171         prefs_win = self.layout_manager.get_widget_by_role('preferencesWindow')
172         prefs_win.update_ui()
173
174     def load_settings(self):
175         '''Loads the settings from persistent storage. Uses defaults if
176         not explicitly configured'''
177         def get_legacy_config():
178             dummyobj = {}
179             tmpdict = {}
180             settingspath = join(self.confpath, 'settings.conf')
181             if os.path.exists(settingspath):
182                 exec open(settingspath) in dummyobj
183                 tmpdict['saving.scrap_prefix'] = dummyobj['save_scrap_prefix']
184                 tmpdict['input.device_mode'] = dummyobj['input_devices_mode']
185                 tmpdict['input.global_pressure_mapping'] = dummyobj['global_pressure_mapping']
186             return tmpdict
187         def get_json_config():
188             settingspath = join(self.confpath, 'settings.json')
189             jsonstr = open(settingspath).read()
190             try:
191                 return helpers.json_loads(jsonstr)
192             except Exception, e:
193                 print "settings.json: %s" % (str(e),)
194                 print "warning: failed to load settings.json, using defaults"
195                 return {}
196         if sys.platform == 'win32':
197             import glib
198             scrappre = join(glib.get_user_special_dir(glib.USER_DIRECTORY_DOCUMENTS).decode('utf-8'),'MyPaint','scrap')
199         else:
200             scrappre = '~/MyPaint/scrap'
201         DEFAULT_CONFIG = {
202             'saving.scrap_prefix': scrappre,
203             'input.device_mode': 'screen',
204             'input.global_pressure_mapping': [(0.0, 1.0), (1.0, 0.0)],
205             'view.default_zoom': 1.0,
206             'view.high_quality_zoom': True,
207             'ui.hide_menubar_in_fullscreen': True,
208             'ui.hide_toolbar_in_fullscreen': True,
209             'ui.hide_subwindows_in_fullscreen': True,
210             'ui.parts': dict(main_toolbar=True, menubar=False),
211             'ui.feedback.scale': True,
212             'ui.feedback.last_pos': False,
213             'ui.toolbar_items': dict(
214                 toolbar1_file=False,
215                 toolbar1_edit=True,
216                 toolbar1_view=False,
217                 toolbar1_subwindows=True,
218             ),
219             'saving.default_format': 'openraster',
220             'brushmanager.selected_brush' : None,
221             'brushmanager.selected_groups' : [],
222
223             "scratchpad.last_opened_scratchpad": "",
224
225             # Default window positions.
226             # See gui.layout.set_window_initial_position for the meanings
227             # of the common x, y, w, and h settings
228             "layout.window_positions": {
229
230                 # Main window default size. Sidebar width is saved here
231                 'main-window': dict(sbwidth=250, x=50, y=32, w=-50, h=-100),
232
233                 # Tool windows. These can be undocked (floating=True) or set
234                 # initially hidden (hidden=True), or be given an initial sidebar
235                 # index (sbindex=<int>) or height in the sidebar (sbheight=<int>)
236                 # Non-hidden entries determine the default set of tools.
237                 'colorSelectionWindow': dict(
238                         sbindex=0, floating=True, hidden=True,
239                         x=-100, y=125, w=160, h=200, sbheight=200),
240                 'colorSamplerWindow': dict(
241                         sbindex=1, floating=True, hidden=True,
242                         x=-270, y=125, w=200, h=275, sbheight=275),
243                 'brushSelectionWindow': dict(
244                         sbindex=2, floating=True, hidden=True,
245                         x=-100, y=-150, w=250, h=350, sbheight=350),
246                 'layersWindow': dict(
247                         sbindex=3, floating=True, hidden=True,
248                         x=-460, y=-150, w=200, h=200, sbheight=200),
249                 'scratchWindow': dict(
250                         sbindex=4, floating=True, hidden=True,
251                         x=-555, y=125, w=300, h=250, sbheight=250),
252
253                 # Non-tool subwindows. These cannot be docked, and are all
254                 # intially hidden.
255                 'brushSettingsWindow': dict(x=-460, y=-128, w=300, h=300),
256                 'backgroundWindow': dict(),
257                 'inputTestWindow': dict(),
258                 'frameWindow': dict(),
259                 'preferencesWindow': dict(),
260             },
261         }
262         if sys.platform == 'win32':
263             # The Linux wacom driver inverts the button numbers of the
264             # pen flip button, because middle-click is the more useful
265             # action on Linux. However one of the two buttons is often
266             # accidentally hit with the thumb while painting. We want
267             # to assign panning to this button by default.
268             DEFAULT_CONFIG.update({
269                 "input.button1_shift_action": 'straight_line',
270                 "input.button1_ctrl_action":  'ColorPickerPopup',
271                 "input.button3_action":       'pan_canvas',
272                 "input.button3_shift_action": 'rotate_canvas',
273                 "input.button3_ctrl_action":  'zoom_canvas',
274                 "input.button2_action":       'ColorHistoryPopup',
275                 "input.button2_shift_action": 'no_action',
276                 "input.button2_ctrl_action":  'no_action',
277                 })
278         else:
279             DEFAULT_CONFIG.update({
280                 "input.button1_shift_action": 'straight_line',
281                 "input.button1_ctrl_action":  'ColorPickerPopup',
282                 "input.button2_action":       'pan_canvas',
283                 "input.button2_shift_action": 'rotate_canvas',
284                 "input.button2_ctrl_action":  'zoom_canvas',
285                 "input.button3_action":       'ColorHistoryPopup',
286                 "input.button3_shift_action": 'no_action',
287                 "input.button3_ctrl_action":  'no_action',
288                 })
289
290         window_pos = DEFAULT_CONFIG["layout.window_positions"]
291         self.window_names = window_pos.keys()
292         self.preferences = DEFAULT_CONFIG
293         try:
294             user_config = get_json_config()
295         except IOError:
296             user_config = get_legacy_config()
297         user_window_pos = user_config.get("layout.window_positions", {})
298         # note: .update() replaces the window position dict, but we want to update it
299         self.preferences.update(user_config)
300         # update window_pos, and drop window names that don't exist any more
301         # (we need to drop them because otherwise we will try to show a non-existing window)
302         for role in self.window_names:
303             if role in user_window_pos:
304                 window_pos[role] = user_window_pos[role]
305         self.preferences["layout.window_positions"] = window_pos
306
307     def add_action_group(self, ag):
308         self.ui_manager.insert_action_group(ag, -1)
309
310     def find_action(self, name):
311         for ag in self.ui_manager.get_action_groups():
312             result = ag.get_action(name)
313             if result is not None:
314                 return result
315
316     def init_brush_adjustments(self):
317         """Initializes all the brush adjustments for the current brush"""
318         self.brush_adjustment = {}
319         from brushlib import brushsettings
320         for i, s in enumerate(brushsettings.settings_visible):
321             adj = gtk.Adjustment(value=s.default, lower=s.min, upper=s.max, step_incr=0.01, page_incr=0.1)
322             self.brush_adjustment[s.cname] = adj
323
324     def update_input_mapping(self):
325         p = self.preferences['input.global_pressure_mapping']
326         if len(p) == 2 and abs(p[0][1]-1.0)+abs(p[1][1]-0.0) < 0.0001:
327             # 1:1 mapping (mapping disabled)
328             self.doc.tdw.pressure_mapping = None
329         else:
330             # TODO: maybe replace this stupid mapping by a hard<-->soft slider?
331             m = mypaintlib.Mapping(1)
332             m.set_n(0, len(p))
333             for i, (x, y) in enumerate(p):
334                 m.set_point(0, i, x, 1.0-y)
335
336             def mapping(pressure):
337                 return m.calculate_single_input(pressure)
338             self.doc.tdw.pressure_mapping = mapping
339
340     def update_input_devices(self):
341         # init extended input devices
342         self.pressure_devices = []
343         for device in gdk.devices_list():
344             #print device.name, device.source
345
346             #if device.source in [gdk.SOURCE_PEN, gdk.SOURCE_ERASER]:
347             # The above contition is True sometimes for a normal USB
348             # Mouse. https://gna.org/bugs/?11215
349             # In fact, GTK also just guesses this value from device.name.
350
351             name = device.name.lower()
352             last_word = name.split()[-1]
353             if last_word == 'pad':
354                 # Setting the intuos3 pad into "screen mode" causes
355                 # glitches when you press a pad-button in mid-stroke,
356                 # and it's not a pointer device anyway. But it reports
357                 # axes almost identical to the pen and eraser.
358                 #
359                 # device.name is usually something like "wacom intuos3 6x8 pad" or just "pad"
360                 print 'Ignoring "%s" (probably wacom keypad device)' % device.name
361                 continue
362             if last_word == 'touchpad':
363                 # eg. "SynPS/2 Synaptics TouchPad"
364                 # Cannot paint at all, cannot select brushes, if we enable this one.
365                 print 'Ignoring "%s" (probably laptop touchpad which screws up gtk+ if enabled)' % device.name
366                 continue
367             if last_word == 'cursor':
368                 # this is a "normal" mouse and does not work in screen mode
369                 print 'Ignoring "%s" (probably wacom mouse device)' % device.name
370                 continue
371             if 'transceiver' in name:
372                 # eg. "Microsoft Microsoft 2.4GHz Transceiver V1.0"
373                 # Cannot paint after moving outside of painting area.
374                 print 'Ignoring "%s" (a transceiver is probably not a pressure sensitive tablet, known to screw up gtk+ when enabled)' % device.name
375                 continue
376
377             for use, val_min, val_max in device.axes:
378                 # Some mice have a third "pressure" axis, but without
379                 # minimum or maximum. https://gna.org/bugs/?14029
380                 if use == gdk.AXIS_PRESSURE and val_min != val_max:
381                     if 'mouse' in device.name.lower():
382                         # Real fix for the above bug https://gna.org/bugs/?14029
383                         print 'Ignoring "%s" (probably a mouse, but it reports extra axes)' % device.name
384                         continue
385
386                     self.pressure_devices.append(device.name)
387                     modesetting = self.preferences['input.device_mode']
388                     mode = getattr(gdk, 'MODE_' + modesetting.upper())
389                     if device.mode != mode:
390                         print 'Setting %s mode for "%s"' % (modesetting, device.name)
391                         device.set_mode(mode)
392                     break
393
394     def save_gui_config(self):
395         gtk.accel_map_save(join(self.confpath, 'accelmap.conf'))
396         self.save_settings()
397
398     def message_dialog(self, text, type=gtk.MESSAGE_INFO, flags=0):
399         """utility function to show a message/information dialog"""
400         d = gtk.MessageDialog(self.drawWindow, flags=flags, buttons=gtk.BUTTONS_OK, type=type)
401         d.set_markup(text)
402         d.run()
403         d.destroy()
404
405     def pick_color_at_pointer(self, widget, size=3):
406         '''Grab screen color at cursor (average of size x size rectangle)'''
407         # inspired by gtkcolorsel.c function grab_color_at_mouse()
408         screen = widget.get_screen()
409         colormap = screen.get_system_colormap()
410         root = screen.get_root_window()
411         screen_w, screen_h = screen.get_width(), screen.get_height()
412         display = widget.get_display()
413         screen_junk, x_root, y_root, modifiermask_trash = display.get_pointer()
414         image = None
415         x = x_root-size/2
416         y = y_root-size/2
417         if x < 0: x = 0
418         if y < 0: y = 0
419         if x+size > screen_w: x = screen_w-size
420         if y+size > screen_h: y = screen_h-size
421         image = root.get_image(x, y, size, size)
422         color_total = (0, 0, 0)
423         for x, y in helpers.iter_rect(0, 0, size, size):
424             pixel = image.get_pixel(x, y)
425             color = colormap.query_color(pixel)
426             color = [color.red, color.green, color.blue]
427             color_total = (color_total[0]+color[0], color_total[1]+color[1], color_total[2]+color[2])
428         N = size*size
429         color_total = (color_total[0]/N, color_total[1]/N, color_total[2]/N)
430         color_rgb = [ch/65535. for ch in color_total]
431         self.brush.set_color_rgb(color_rgb)
432
433 class PixbufDirectory:
434     def __init__(self, dirname):
435         self.dirname = dirname
436         self.cache = {}
437
438     def __getattr__(self, name):
439         if name not in self.cache:
440             try:
441                 pixbuf = gdk.pixbuf_new_from_file(join(self.dirname, name + '.png'))
442             except gobject.GError, e:
443                 raise AttributeError, str(e)
444             self.cache[name] = pixbuf
445         return self.cache[name]