1 # This file is part of MyPaint.
2 # Copyright (C) 2007 by Martin Renold <martinxyz@gmx.ch>
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License.
6 # This program is distributed in the hope that it will be useful,
7 # but WITHOUT ANY WARRANTY. See the COPYING file for more details.
9 "interface to MyBrush; hiding some C implementation details"
10 # FIXME: bad file name, saying nothing about what's in here
11 # FIXME: should split brush_lowlevel into its own gtk-independent module
13 from brushlib import brushsettings
14 import gtk, string, os, colorsys
15 from helpers import clamp
22 current_brushfile_version = 2
24 def pixbuf_scale_nostretch_centered(src, dst):
25 scale_x = float(dst.get_width()) / src.get_width()
26 scale_y = float(dst.get_height()) / src.get_height()
31 offset_x = (dst.get_width() - src.get_width() * scale) / 2
34 offset_y = (dst.get_height() - src.get_height() * scale) / 2
36 src.scale(dst, 0, 0, dst.get_width(), dst.get_height(),
37 offset_x, offset_y, scale, scale,
38 gtk.gdk.INTERP_BILINEAR)
40 # points = [(x1, y1), (x2, y2), ...] (at least two points, or None)
42 "a specific setting for a specific brush"
43 def __init__(self, setting, parent_brush, observers):
44 self.setting = setting
45 self.brush = parent_brush
46 self.observers = observers
47 self.base_value = None
48 self.set_base_value(setting.default)
49 self.points = [[] for i in xrange(len(brushsettings.inputs))]
50 if setting.cname == 'opaque_multiply':
51 # make opaque depend on pressure by default
52 for i in brushsettings.inputs:
53 if i.name == 'pressure': break
54 self.set_points(i, [(0.0, 0.0), (1.0, 1.0)])
55 def set_base_value(self, value):
56 if self.base_value == value: return
57 self.base_value = value
58 self.brush.set_base_value(self.setting.index, value)
59 for f in self.observers: f()
60 def has_only_base_value(self):
61 for i in brushsettings.inputs:
65 def has_input(self, input):
66 return self.points[input.index]
67 def has_input_nonlinear(self, input):
68 points = self.points[input.index]
69 if not points: return False
70 if len(points) > 2: return True
71 # also if it is linear but the x-axis was changed (hm, bad function name)
72 if abs(points[0][0] - input.soft_min) > 0.001: return True
73 if abs(points[1][0] - input.soft_max) > 0.001: return True
76 def set_points(self, input, points):
77 assert len(points) != 1
78 if self.points[input.index] == points: return
80 # print 'set_points[%s](%s, %s)' % (self.setting.cname, input.name, points)
82 self.brush.set_mapping_n(self.setting.index, input.index, len(points))
83 for i, (x, y) in enumerate(points):
84 self.brush.set_mapping_point(self.setting.index, input.index, i, x, y)
86 self.points[input.index] = points[:] # copy
87 for f in self.observers: f()
89 def copy_from(self, other):
90 error = self.load_from_string(other.save_to_string(), version=current_brushfile_version)
91 assert not error, error
92 def save_to_string(self):
93 s = str(self.base_value)
94 for i in brushsettings.inputs:
95 points = self.points[i.index]
97 s += ' | ' + i.name + ' ' + ', '.join(['(%f %f)' % xy for xy in points])
99 def load_from_string(self, s, version):
102 self.set_base_value(float(parts[0]))
103 for i in brushsettings.inputs:
104 self.set_points(i, [])
105 for part in parts[1:]:
106 command, args = part.strip().split(' ', 1)
107 if version <= 1 and command == 'speed': command = 'speed1'
108 i = brushsettings.inputs_dict.get(command)
111 points_old = [float(f) for f in args.split()]
114 x = points_old.pop(0)
115 y = points_old.pop(0)
117 assert x > points[-1][0]
118 points.append((x, y))
121 for s in args.split(', '):
123 if not (s.startswith('(') and s.endswith(')') and ' ' in s):
124 return '(x y) expected, got "%s"' % s
127 x, y = [float(ss) for ss in s.split(' ')]
131 points.append((x, y))
132 assert len(points) >= 2
133 self.set_points(i, points)
135 error = 'unknown input "%s"' % command
137 def transform_y(self, func):
138 # useful for migration from a earlier version
139 self.set_base_value(func(self.base_value))
140 for i in brushsettings.inputs:
141 if not self.points[i.index]: continue
142 points = self.points[i.index]
143 points = [(x, func(y)) for x, y in points]
144 self.set_points(i, points)
146 class Brush_Lowlevel(mypaintlib.Brush):
148 mypaintlib.Brush.__init__(self)
149 self.settings_observers = []
150 self.settings_observers_hidden = []
152 for s in brushsettings.settings:
153 self.settings.append(Setting(s, self, self.settings_observers))
155 self.saved_string = None
156 self.settings_observers.append(self.invalidate_saved_string)
158 def invalidate_saved_string(self):
159 self.saved_string = None
161 def begin_atomic(self):
162 self.settings_observers_hidden.append(self.settings_observers[:])
163 del self.settings_observers[:]
165 def end_atomic(self):
166 self.settings_observers[:] = self.settings_observers_hidden.pop()
167 for f in self.settings_observers: f()
170 def get_stroke_bbox(self):
171 bbox = self.stroke_bbox
172 return bbox.x, bbox.y, bbox.w, bbox.h
174 def setting_by_cname(self, cname):
175 s = brushsettings.settings_dict[cname]
176 return self.settings[s.index]
178 def save_to_string(self):
179 # OPTIMIZE: this cache could be more useful, the current "copy_settings_from()"
180 # brush selection mechanism invalidates it at every brush change
181 if self.saved_string: return self.saved_string
182 res = '# mypaint brush file\n'
183 res += '# you can edit this file and then select the brush in mypaint (again) to reload\n'
184 res += 'version %d\n' % current_brushfile_version
185 for s in brushsettings.settings:
186 res += s.cname + ' ' + self.settings[s.index].save_to_string() + '\n'
187 self.saved_string = res
190 def load_from_string(self, s):
194 version = 1 # for files without a 'version' field
195 for line in s.split('\n'):
197 if line.startswith('#'): continue
198 if not line: continue
200 command, rest = line.split(' ', 1)
203 if command in brushsettings.settings_dict:
204 setting = self.setting_by_cname(command)
205 error = setting.load_from_string(rest, version)
206 elif command in brushsettings.settings_migrate:
207 command_new, transform_func = brushsettings.settings_migrate[command]
208 setting = self.setting_by_cname(command_new)
209 error = setting.load_from_string(rest, version)
211 setting.transform_y(transform_func)
212 elif command == 'version':
214 if version > current_brushfile_version:
215 error = 'this brush was saved with a more recent version of mypaint'
216 elif version <= 1 and command == 'color':
217 self.set_color_rgb([int(s)/255.0 for s in rest.split()])
218 elif version <= 1 and command == 'change_radius':
219 if rest != '0.0': error = 'change_radius is not supported any more'
220 elif version <= 2 and command == 'adapt_color_from_image':
221 if rest != '0.0': error = 'adapt_color_from_image is obsolete, ignored; use smudge and smudge_length instead'
222 elif version <= 1 and command == 'painting_time':
225 error = 'unknown command, line ignored'
228 errors.append((line, error))
231 errors.append((line, str(e)))
235 errors.append(('', 'there was only garbage in this file, using defaults'))
239 def copy_settings_from(self, other):
241 for i, setting in enumerate(self.settings):
242 setting.copy_from(other.settings[i])
245 def get_color_hsv(self):
246 h = self.setting_by_cname('color_h').base_value
247 s = self.setting_by_cname('color_s').base_value
248 v = self.setting_by_cname('color_v').base_value
251 def set_color_hsv(self, hsv):
254 self.setting_by_cname('color_h').set_base_value(h)
255 self.setting_by_cname('color_s').set_base_value(s)
256 self.setting_by_cname('color_v').set_base_value(v)
259 def set_color_rgb(self, rgb):
260 for i in range(3): assert rgb[i] <= 1.0
261 self.set_color_hsv(colorsys.rgb_to_hsv(*rgb))
263 def get_color_rgb(self):
264 hsv = self.get_color_hsv()
265 hsv = [clamp(x, 0.0, 1.0) for x in hsv]
266 return colorsys.hsv_to_rgb(*hsv)
268 def invert_color(self):
269 rgb = self.get_color_rgb()
270 rgb = [1-x for x in rgb]
271 self.set_color_rgb(rgb)
274 class Brush(Brush_Lowlevel):
275 def __init__(self, app):
276 Brush_Lowlevel.__init__(self)
279 self.preview_thumb = None
281 self.preview_changed = True
283 self.settings_mtime = None
284 self.preview_mtime = None
286 def get_fileprefix(self, saving=False):
288 if os.path.realpath(self.app.user_brushpath) == os.path.realpath(self.app.stock_brushpath):
289 #if os.path.samefile(self.app.user_brushpath, self.app.stock_brushpath):
290 # working directly on brush collection, use different prefix
296 self.name = '%s%03d' % (prefix, i)
297 a = os.path.join(self.app.user_brushpath,self.name + '.myb')
298 b = os.path.join(self.app.stock_brushpath,self.name + '.myb')
299 if not os.path.isfile(a) and not os.path.isfile(b):
302 prefix = os.path.join(self.app.user_brushpath, self.name)
305 if not os.path.isfile(prefix + '.myb'):
306 prefix = os.path.join(self.app.stock_brushpath,self.name)
307 assert os.path.isfile(prefix + '.myb'), 'brush "' + self.name + '" not found'
310 def delete_from_disk(self):
311 prefix = os.path.join(self.app.user_brushpath, self.name)
312 if os.path.isfile(prefix + '.myb'):
313 os.remove(prefix + '_prev.png')
314 os.remove(prefix + '.myb')
316 prefix = os.path.join(self.app.stock_brushpath, self.name)
317 if os.path.isfile(prefix + '.myb'):
318 # user wants to delete a stock brush
319 # cannot remove the file, manage blacklist instead
320 filename = os.path.join(self.app.user_brushpath, 'deleted.conf')
321 new = not os.path.isfile(filename)
322 f = open(filename, 'a')
324 f.write('# list of stock brush names which you have deleted\n')
325 f.write('# you can remove this file to get all of them back\n')
326 f.write(self.name + '\n')
329 def remember_mtimes(self):
330 prefix = self.get_fileprefix()
331 self.preview_mtime = os.path.getmtime(prefix + '_prev.png')
332 self.settings_mtime = os.path.getmtime(prefix + '.myb')
334 def has_changed_on_disk(self):
335 prefix = self.get_fileprefix()
336 if self.preview_mtime != os.path.getmtime(prefix + '_prev.png'): return True
337 if self.settings_mtime != os.path.getmtime(prefix + '.myb'): return True
341 prefix = self.get_fileprefix(saving=True)
342 if self.preview_changed:
343 self.preview.save(prefix + '_prev.png', 'png')
344 self.preview_changed = False
345 open(prefix + '.myb', 'w').write(self.save_to_string())
346 self.remember_mtimes()
348 def load(self, name):
350 prefix = self.get_fileprefix()
352 filename = prefix + '_prev.png'
353 pixbuf = gtk.gdk.pixbuf_new_from_file(filename)
354 self.update_preview(pixbuf)
356 if prefix.startswith(self.app.user_brushpath):
357 self.preview_changed = False
359 # for saving, create the preview file even if not changed
360 self.preview_changed = True
362 filename = prefix + '.myb'
363 errors = self.load_from_string(open(filename).read())
365 print '%s:' % filename
366 for line, reason in errors:
371 self.remember_mtimes()
373 def reload_if_changed(self):
374 if self.settings_mtime is None: return
375 if self.preview_mtime is None: return
376 if not self.name: return
377 prefix = self.get_fileprefix()
378 if not self.has_changed_on_disk(): return False
379 print 'Brush "' + self.name + '" has changed on disk, reloading it.'
383 def update_preview(self, pixbuf):
384 self.preview = pixbuf
385 self.preview_thumb = gtk.gdk.Pixbuf(gtk.gdk.COLORSPACE_RGB, False, 8, thumb_w, thumb_h)
386 pixbuf_scale_nostretch_centered(src=pixbuf, dst=self.preview_thumb)
387 self.preview_changed = True