OSDN Git Service

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