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