OSDN Git Service

version bump
[mypaint-anime/master.git] / gui / brushmanager.py
1 # This file is part of MyPaint.
2 # Copyright (C) 2009 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 """
10 This module does file management for brushes and brush groups.
11 """
12
13 import dialogs
14 import gtk
15 from gtk import gdk # only for gdk.pixbuf
16 from gettext import gettext as _
17 import os, zipfile
18 from os.path import basename
19 import urllib
20 import gobject
21 from lib.brush import BrushInfo
22 from warnings import warn
23
24 preview_w = 128
25 preview_h = 128
26
27 DEFAULT_STARTUP_GROUP = 'set#2'  # Suggestion only
28 DEFAULT_BRUSH = 'deevad/artpen'  # TODO: phase out and use heuristics?
29 DEFAULT_ERASER = 'deevad/stick'  # TODO: ---------------"--------------
30 FOUND_BRUSHES_GROUP = 'lost&found'
31 DELETED_BRUSH_GROUP = 'deleted'
32 FAVORITES_BRUSH_GROUP = 'favorites'
33 DEVBRUSH_NAME_PREFIX = "devbrush_"
34 BRUSH_HISTORY_NAME_PREFIX = "history_"
35 BRUSH_HISTORY_SIZE = 5
36 NUM_BRUSHKEYS = 10
37
38 def devbrush_quote(device_name, prefix=DEVBRUSH_NAME_PREFIX):
39     """
40     Quotes an arbitrary device name for use as the basename of a
41     device-specific brush.
42
43         >>> devbrush_quote(u'Heavy Metal Umlaut D\u00ebvice')
44         'devbrush_Heavy+Metal+Umlaut+D%C3%ABvice'
45         >>> devbrush_quote(u'unsafe/device\u005Cname') # U+005C == backslash
46         'devbrush_unsafe%2Fdevice%5Cname'
47
48     Hopefully this is OK for Windows, UNIX and Mac OS X names.
49     """
50     device_name = unicode(device_name)
51     u8bytes = device_name.encode("utf-8")
52     quoted = urllib.quote_plus(u8bytes, safe='')
53     return prefix + quoted
54
55 def devbrush_unquote(devbrush_name, prefix=DEVBRUSH_NAME_PREFIX):
56     """
57     Unquotes the basename of a devbrush for use when matching device names.
58
59         >>> expected = "My sister was bitten by a m\u00f8\u00f8se..."
60         >>> quoted = 'devbrush_My+sister+was+bitten+by+a+m%5Cu00f8%5Cu00f8se...'
61         >>> devbrush_unquote(quoted) == expected
62         True
63     """
64     devbrush_name = str(devbrush_name)
65     assert devbrush_name.startswith(prefix)
66     quoted = devbrush_name[len(prefix):]
67     u8bytes = urllib.unquote_plus(quoted)
68     return unicode(u8bytes.decode("utf-8"))
69
70 def translate_group_name(name):
71     d = {FOUND_BRUSHES_GROUP: _('Lost & Found'),
72          DELETED_BRUSH_GROUP: _('Deleted'),
73          FAVORITES_BRUSH_GROUP: _('Favorites'),
74          'ink': _('Ink'),
75          'classic': _('Classic'),
76          'set#1': _('Set#1'),
77          'set#2': _('Set#2'),
78          'set#3': _('Set#3'),
79          'set#4': _('Set#4'),
80          'set#5': _('Set#5'),
81          'experimental': _('Experimental'),
82          }
83     return d.get(name, name)
84
85 def parse_order_conf(file_content):
86     # parse order.conf file returing a dict like this:
87     # {'group1' : ['brush1', 'brush2'], 'group2' : ['brush3']}
88     groups = {}
89     curr_group = FOUND_BRUSHES_GROUP
90     lines = file_content.replace('\r', '\n').split('\n')
91     for line in lines:
92         name = line.strip().decode('utf-8')
93         if name.startswith('#') or not name:
94             continue
95         if name.startswith('Group: '):
96             curr_group = name[7:]
97             if curr_group not in groups:
98                 groups[curr_group] = []
99             continue
100         groups.setdefault(curr_group, [])
101         if name in groups[curr_group]:
102             print name + ': Warning: brush appears twice in the same group, ignored'
103             continue
104         groups[curr_group].append(name)
105     return groups
106
107 class BrushManager:
108     def __init__(self, stock_brushpath, user_brushpath, app):
109         self.stock_brushpath = stock_brushpath
110         self.user_brushpath = user_brushpath
111         self.app = app
112
113         self.selected_brush = None
114         self.groups = {}
115         self.contexts = []
116         self.active_groups = []
117         self.loaded_groups = []
118         self.brush_by_device = {} # should be save/loaded too?
119         self.selected_context = None
120
121         self.selected_brush_observers = []
122         self.groups_observers = [] # for both self.groups and self.active_groups
123         self.brushes_observers = [] # for all brushlists inside groups
124
125         if not os.path.isdir(self.user_brushpath):
126             os.mkdir(self.user_brushpath)
127         self.load_groups()
128
129         # Retreive which groups were last open, or default to a nice/sane set.
130         last_active_groups = self.app.preferences['brushmanager.selected_groups']
131         if not last_active_groups:
132             if DEFAULT_STARTUP_GROUP in self.groups:
133                 last_active_groups = [DEFAULT_STARTUP_GROUP]
134             elif self.groups:
135                 group_names = self.groups.keys()
136                 group_names.sort()
137                 last_active_groups = [group_names[0]]
138             else:
139                 last_active_groups = []
140         for group in reversed(last_active_groups):
141             if group in self.groups:
142                 brushes = self.get_group_brushes(group, make_active=True)
143
144         self.brushes_observers.append(self.brushes_modified_cb)
145
146         self.app.doc.input_stroke_ended_observers.append(self.input_stroke_ended_cb)
147
148     def select_initial_brush(self):
149         initial_brush = None
150         # If we recorded which devbrush was last in use, restore it and assume
151         # that most of the time the user will continue to work with the same
152         # brush and its settings.
153         last_used_devbrush = self.app.preferences.get('devbrush.last_used', None)
154         initial_brush = self.brush_by_device.get(last_used_devbrush, None)
155         # Otherwise, initialise from the old selected_brush setting
156         if initial_brush is None:
157             last_active_name = self.app.preferences['brushmanager.selected_brush']
158             if last_active_name is not None:
159                 initial_brush = self.get_brush_by_name(last_active_name)
160         # Fallback
161         if initial_brush is None:
162             initial_brush = self.get_default_brush()
163         self.select_brush(initial_brush)
164
165     def get_matching_brush(self, name=None, keywords=None,
166                            favored_group=DEFAULT_STARTUP_GROUP,
167                            fallback_eraser=0.0):
168         """Gets a brush robustly by name, by partial name, or a default.
169
170         If a brush named `name` exists, use that. Otherwise search though all
171         groups, `favored_group` first, for brushes with any of `keywords`
172         in their name. If that fails, construct a new default brush and use
173         a given value for its 'eraser' property.
174         """
175         if name is not None:
176             brush = self.get_brush_by_name(name)
177             if brush is not None:
178                 return brush
179         if keywords is not None:
180             group_names = self.groups.keys()
181             group_names.sort()
182             if favored_group in self.groups:
183                 group_names.remove(favored_group)
184                 group_names.insert(0, favored_group)
185             for group_name in group_names:
186                 for brush in self.groups[group_name]:
187                     for keyword in keywords:
188                         if keyword in brush.name:
189                             return brush
190         # Fallback
191         name = 'fallback-default'
192         if fallback_eraser != 0.0:
193             name += '-eraser'
194         brush = ManagedBrush(self, name)
195         brush.brushinfo.set_base_value("eraser", fallback_eraser)
196         return brush
197
198
199     def get_default_brush(self):
200         """Returns a suitable default drawing brush."""
201         return self.get_matching_brush(name=DEFAULT_BRUSH,
202                                 keywords=["pencil", "charcoal", "sketch"])
203
204
205     def get_default_eraser(self):
206         """Returns a suitable default eraser brush."""
207         return self.get_matching_brush(name=DEFAULT_ERASER,
208                                 keywords=["eraser", "kneaded", "smudge"],
209                                 fallback_eraser=1.0)
210
211
212
213     def load_groups(self):
214         self.contexts = [None for i in xrange(NUM_BRUSHKEYS)]
215         self.history = [None for i in xrange(BRUSH_HISTORY_SIZE)]
216
217         brush_by_name = {}
218         def get_brush(name):
219             if name not in brush_by_name:
220                 b = ManagedBrush(self, name, persistent=True)
221                 brush_by_name[name] = b
222             return brush_by_name[name]
223
224         def read_groups(filename):
225             groups = {}
226             if os.path.exists(filename):
227                 groups = parse_order_conf(open(filename).read())
228                 # replace brush names with ManagedBrush instances
229                 for group, names in groups.items():
230                     brushes = []
231                     for name in names:
232                         try:
233                             b = get_brush(name)
234                         except IOError, e:
235                             print e, '(removed from group)'
236                             continue
237                         brushes.append(b)
238                     groups[group] = brushes
239             return groups
240
241         # tree-way-merge of brush groups (for upgrading)
242         base  = read_groups(os.path.join(self.user_brushpath,  'order_default.conf'))
243         our   = read_groups(os.path.join(self.user_brushpath,  'order.conf'))
244         their = read_groups(os.path.join(self.stock_brushpath, 'order.conf'))
245
246         if not our:
247             # order.conf missing, restore stock order even if order_default.conf exists
248             base = {}
249
250         if base == their:
251             self.groups = our
252         else:
253             print 'Merging upstream brush changes into your collection.'
254             groups = set(base).union(our).union(their)
255             for group in groups:
256                 # treat the non-existing groups as if empty
257                 base_brushes = base.setdefault(group, [])
258                 our_brushes = our.setdefault(group, [])
259                 their_brushes = their.setdefault(group, [])
260                 # add new brushes
261                 insert_index = 0
262                 for b in their_brushes:
263                     if b in our_brushes:
264                         insert_index = our_brushes.index(b) + 1
265                     else:
266                         if b not in base_brushes:
267                             our_brushes.insert(insert_index, b)
268                             insert_index += 1
269                 # remove deleted brushes
270                 for b in base_brushes:
271                     if b not in their_brushes and b in our_brushes:
272                         our_brushes.remove(b)
273                 # remove empty groups (except for the favorites)
274                 if not our_brushes and group != FAVORITES_BRUSH_GROUP:
275                     del our[group]
276             # finish
277             self.groups = our
278             self.save_brushorder()
279             data = open(os.path.join(self.stock_brushpath, 'order.conf')).read()
280             open(os.path.join(self.user_brushpath,  'order_default.conf'), 'w').write(data)
281
282         # check for brushes that are in the brush directory, but not in any group
283
284         def listbrushes(path):
285             # Return a list of brush names relative to path, using
286             # slashes for subirectories on all platforms.
287             path += '/'
288             l = []
289             assert isinstance(path, unicode) # make sure we get unicode filenames 
290             for name in os.listdir(path):
291                 assert isinstance(name, unicode)
292                 if name.endswith('.myb'):
293                     l.append(name[:-4])
294                 elif os.path.isdir(path+name):
295                     for name2 in listbrushes(path+name):
296                         l.append(name + '/' + name2)
297             return l
298
299         # Distinguish between brushes in the brushlist and those that are not;
300         # handle lost-and-found ones.
301         for name in listbrushes(self.stock_brushpath) + listbrushes(self.user_brushpath):
302             b = get_brush(name)
303             b.in_brushlist = True
304             if name.startswith('context'):
305                 i = int(name[-2:])
306                 self.contexts[i] = b
307                 b.load_settings(retain_parent=True)
308                 b.in_brushlist = False
309             elif name.startswith(DEVBRUSH_NAME_PREFIX):
310                 device_name = devbrush_unquote(name)
311                 self.brush_by_device[device_name] = b
312                 b.load_settings(retain_parent=True)
313                 b.in_brushlist = False
314             elif name.startswith(BRUSH_HISTORY_NAME_PREFIX):
315                 i_str = name.replace(BRUSH_HISTORY_NAME_PREFIX, '')
316                 i = int(i_str)
317                 self.history[i] = b
318                 b.load_settings(retain_parent=True)
319                 b.in_brushlist = False
320             if b.in_brushlist:
321                 if not [True for group in our.itervalues() if b in group]:
322                     brushes = self.groups.setdefault(FOUND_BRUSHES_GROUP, [])
323                     brushes.insert(0, b)
324
325         # Sensible defaults for brushkeys and history: clone the first few
326         # brushes from a normal group if we need to and if we can.
327         # Try the default startup group first.
328         default_group = self.groups.get(DEFAULT_STARTUP_GROUP, None)
329
330         # Otherwise, use the biggest group to minimise the chance
331         # of repetition.
332         if default_group is None:
333             groups_by_len = [(len(g),n,g) for n,g in self.groups.items()]
334             groups_by_len.sort()
335             _len, _name, default_group = groups_by_len[-1]
336
337         # Populate blank entries.
338         for i in xrange(NUM_BRUSHKEYS):
339             if self.contexts[i] is None:
340                 idx = (i+9) % 10 # keyboard order
341                 c_name = unicode('context%02d') % i
342                 c = ManagedBrush(self, name=c_name, persistent=False)
343                 group_idx = idx % len(default_group)
344                 b = default_group[group_idx]
345                 b.clone_into(c, c_name)
346                 self.contexts[i] = c
347         for i in xrange(BRUSH_HISTORY_SIZE):
348             if self.history[i] is None:
349                 h_name = unicode('%s%d') % (BRUSH_HISTORY_NAME_PREFIX, i)
350                 h = ManagedBrush(self, name=h_name, persistent=False)
351                 group_i = i % len(default_group)
352                 b = default_group[group_i]
353                 b.clone_into(h, h_name)
354                 self.history[i] = h
355
356         # clean up legacy stuff
357         fn = os.path.join(self.user_brushpath, 'deleted.conf')
358         if os.path.exists(fn):
359             os.remove(fn)
360
361     def import_brushpack(self, path,  window):
362         zip = zipfile.ZipFile(path)
363         names = zip.namelist()
364         # zipfile does utf-8 decoding on its own; this is just to make
365         # sure we have only unicode objects as brush names.
366         names = [s.decode('utf-8') for s in names]
367
368         readme = None
369         if 'readme.txt' in names:
370             readme = zip.read('readme.txt')
371
372         assert 'order.conf' in names, 'invalid brushpack: order.conf missing'
373         groups = parse_order_conf(zip.read('order.conf'))
374
375         new_brushes = []
376         for brushes in groups.itervalues():
377             for brush in brushes:
378                 if brush not in new_brushes:
379                     new_brushes.append(brush)
380         print len(new_brushes), 'different brushes found in order.conf of brushpack'
381
382         # Validate file content. The names in order.conf and the
383         # brushes found in the zip must match. This should catch
384         # encoding screwups, everything should be an unicode object.
385         for brush in new_brushes:
386             assert brush + '.myb' in names, 'invalid brushpack: brush %r in order.conf does not exist in zip' % brush
387         for name in names:
388             if name.endswith('.myb'):
389                 brush = name[:-4]
390                 assert brush in new_brushes, 'invalid brushpack: brush %r exists in zip, but not in order.conf' % brush
391
392         if readme:
393             answer = dialogs.confirm_brushpack_import(basename(path), window, readme)
394             if answer == gtk.RESPONSE_REJECT:
395                 return
396
397         do_overwrite = False
398         do_ask = True
399         renamed_brushes = {}
400         final_groups = []
401         for groupname, brushes in groups.iteritems():
402             managed_brushes = self.get_group_brushes(groupname)
403             self.set_active_groups([groupname])
404             if managed_brushes:
405                 answer = dialogs.confirm_rewrite_group(
406                     window, translate_group_name(groupname), translate_group_name(DELETED_BRUSH_GROUP))
407                 if answer == dialogs.CANCEL:
408                     return
409                 elif answer == dialogs.OVERWRITE_THIS:
410                     self.delete_group(groupname)
411                 elif answer == dialogs.DONT_OVERWRITE_THIS:
412                     i = 0
413                     old_groupname = groupname
414                     while groupname in self.groups:
415                         i += 1
416                         groupname = old_groupname + '#%d' % i
417                 managed_brushes = self.get_group_brushes(groupname, make_active=True)
418
419             final_groups.append(groupname)
420
421             for brushname in brushes:
422                 # extract the brush from the zip
423                 assert (brushname + '.myb') in zip.namelist()
424                 # Support for utf-8 ZIP filenames that don't have the utf-8 bit set.
425                 brushname_utf8 = brushname.encode('utf-8')
426                 try:
427                     myb_data = zip.read(brushname + '.myb')
428                 except KeyError:
429                     myb_data = zip.read(brushname_utf8 + '.myb')
430                 try:
431                     preview_data = zip.read(brushname + '_prev.png')
432                 except KeyError:
433                     preview_data = zip.read(brushname_utf8 + '_prev.png')
434                 # in case we have imported that brush already in a previous group, but decided to rename it
435                 if brushname in renamed_brushes:
436                     brushname = renamed_brushes[brushname]
437                 # possibly ask how to import the brush file (if we didn't already)
438                 b = self.get_brush_by_name(brushname)
439                 if brushname in new_brushes:
440                     new_brushes.remove(brushname)
441                     if b:
442                         existing_preview_pixbuf = b.preview
443                         if do_ask:
444                             answer = dialogs.confirm_rewrite_brush(window, brushname, existing_preview_pixbuf, preview_data)
445                             if answer == dialogs.CANCEL:
446                                 break
447                             elif answer == dialogs.OVERWRITE_ALL:
448                                 do_overwrite = True
449                                 do_ask = False
450                             elif answer == dialogs.OVERWRITE_THIS:
451                                 do_overwrite = True
452                                 do_ask = True
453                             elif answer == dialogs.DONT_OVERWRITE_THIS:
454                                 do_overwrite = False
455                                 do_ask = True
456                             elif answer == dialogs.DONT_OVERWRITE_ANYTHING:
457                                 do_overwrite = False
458                                 do_ask = False
459                         # find a new name (if requested)
460                         brushname_old = brushname
461                         i = 0
462                         while not do_overwrite and b:
463                             i += 1
464                             brushname = brushname_old + '#%d' % i
465                             renamed_brushes[brushname_old] = brushname
466                             b = self.get_brush_by_name(brushname)
467
468                     if not b:
469                         b = ManagedBrush(self, brushname)
470
471                     # write to disk and reload brush (if overwritten)
472                     prefix = b.get_fileprefix(saving=True)
473                     myb_f = open(prefix + '.myb', 'w')
474                     myb_f.write(myb_data)
475                     myb_f.close()
476                     preview_f = open(prefix + '_prev.png', 'wb')
477                     preview_f.write(preview_data)
478                     preview_f.close()
479                     b.load()
480                     b.in_brushlist = True
481                 # finally, add it to the group
482                 if b not in managed_brushes:
483                     managed_brushes.append(b)
484                 for f in self.brushes_observers: f(managed_brushes)
485
486         if DELETED_BRUSH_GROUP in self.groups:
487             # remove deleted brushes that are in some group again
488             self.delete_group(DELETED_BRUSH_GROUP)
489         self.set_active_groups(final_groups)
490
491     def export_group(self, group, filename):
492         zip = zipfile.ZipFile(filename, mode='w')
493         brushes = self.get_group_brushes(group)
494         order_conf = 'Group: %s\n' % group.encode('utf-8')
495         for brush in brushes:
496             prefix = brush.get_fileprefix()
497             zip.write(prefix + '.myb', brush.name + '.myb')
498             zip.write(prefix + '_prev.png', brush.name + '_prev.png')
499             order_conf += brush.name.encode('utf-8') + '\n'
500         zip.writestr('order.conf', order_conf)
501         zip.close()
502
503     def get_brush_by_name(self, name):
504         # slow method, should not be called too often
505         # FIXME: speed up, use a dict.
506         for group, brushes in self.groups.iteritems():
507             for b in brushes:
508                 if b.name == name:
509                     return b
510
511     def brushes_modified_cb(self, brushes):
512         self.save_brushorder()
513
514     def save_brushorder(self):
515         f = open(os.path.join(self.user_brushpath, 'order.conf'), 'w')
516         f.write('# this file saves brush groups and order\n')
517         for group, brushes in self.groups.iteritems():
518             f.write('Group: %s\n' % group.encode('utf-8'))
519             for b in brushes:
520                 f.write(b.name.encode('utf-8') + '\n')
521         f.close()
522
523
524     def input_stroke_ended_cb(self, *junk):
525         """Update brush history at the end of an input stroke.
526         """
527         b = self.app.brush
528         b_parent = b.get_string_property("parent_brush_name")
529         for i, h in enumerate(self.history):
530             h_parent = h.brushinfo.get_string_property("parent_brush_name")
531             # Possibly we should use a tighter equality check than this, but
532             # then we'd need icons showing modifications from the parent.
533             if b_parent == h_parent:
534                 del self.history[i]
535                 break
536         h = ManagedBrush(self, name=None, persistent=False)
537         h.brushinfo = b.clone()
538         h.preview = self.selected_brush.preview
539         self.history.append(h)
540         while len(self.history) > BRUSH_HISTORY_SIZE:
541             del self.history[0]
542         for i, h in enumerate(self.history):
543             h.name = u"%s%d" % (BRUSH_HISTORY_NAME_PREFIX, i)
544
545
546     def select_brush(self, brush):
547         """Selects a ManagedBrush, highlights it, & updates the live brush."""
548         if brush is None:
549             brush = self.get_default_brush()
550         if brush.persistent and not brush.settings_loaded:   # XXX refactor
551             brush.load_settings()
552
553         brushinfo = brush.brushinfo
554         if not brush.in_brushlist:
555             # select parent brush instead, but keep brushinfo
556             brush = self.get_parent_brush(brush=brush)
557             if not brush:
558                 # no parent, select an empty brush instead
559                 brush = ManagedBrush(self)
560
561         self.selected_brush = brush
562         self.app.preferences['brushmanager.selected_brush'] = brush.name
563         # Take care of updating the live brush, amongst other things
564         for callback in self.selected_brush_observers:
565             callback(brush, brushinfo)
566
567
568     def get_parent_brush(self, brush=None, brushinfo=None):
569         """Gets the parent `ManagedBrush` for a brush or a `BrushInfo`.
570         """
571         if brush is not None:
572             if brush.persistent and not brush.settings_loaded:   # XXX refactor
573                 brush.load_settings()
574             brushinfo = brush.brushinfo
575         if brushinfo is None:
576             raise RuntimeError, "One of `brush` or `brushinfo` must be defined."
577         parent_name = brushinfo.get_string_property("parent_brush_name")
578         if parent_name is None:
579             return None
580         else:
581             parent_brush = self.get_brush_by_name(parent_name)
582             if parent_brush is None:
583                 return None
584             if parent_brush.persistent and not parent_brush.settings_loaded:  # XXX refactor
585                 parent_brush.load_settings()
586             return parent_brush
587
588
589     def clone_selected_brush(self, name):
590         """
591         Creates a new ManagedBrush based on the selected
592         brush in the brushlist and the currently active lib.brush.
593         """
594         clone = ManagedBrush(self, name, persistent=False)
595         clone.brushinfo = self.app.brush.clone()
596         clone.preview = self.selected_brush.preview
597         parent = self.selected_brush.name
598         clone.brushinfo.set_string_property("parent_brush_name", parent)
599         return clone
600
601     def store_brush_for_device(self, device_name, managed_brush):
602         """
603         Records an existing ManagedBrush as associated with a given input device.
604
605         Normally the brush will be cloned first, since it will be given a new
606         name. However, if the ManagedBrush has a 'name' attribute of None, it
607         will *not* be cloned and just modified in place and stored.
608         """
609         brush = managed_brush
610         if brush.name is not None:
611             brush = brush.clone()
612         brush.name = unicode(devbrush_quote(device_name))
613         self.brush_by_device[device_name] = brush
614
615     def fetch_brush_for_device(self, device_name):
616         """
617         Fetches the brush associated with a particular input device name.
618         """
619         devbrush_name = devbrush_quote(device_name)
620         brush = self.brush_by_device.get(device_name, None)
621         return brush
622
623     def save_brushes_for_devices(self):
624         for device_name, devbrush in self.brush_by_device.iteritems():
625             devbrush.save()
626
627     def save_brush_history(self):
628         for brush in self.history:
629             brush.save()
630
631     def set_active_groups(self, groups):
632         """Set active groups."""
633         self.active_groups = groups
634         self.app.preferences['brushmanager.selected_groups'] = groups
635         for f in self.groups_observers: f()
636
637     def get_group_brushes(self, group, make_active=False):
638         if group not in self.groups:
639             brushes = []
640             self.groups[group] = brushes
641             for f in self.groups_observers: f()
642             self.save_brushorder()
643         if make_active and group not in self.active_groups:
644             self.set_active_groups([group] + self.active_groups)
645         return self.groups[group]
646
647     def create_group(self, new_group, make_active=True):
648         return self.get_group_brushes(new_group, make_active)
649
650     def rename_group(self, old_group, new_group):
651         was_active = (old_group in self.active_groups)
652         brushes = self.create_group(new_group, make_active=was_active)
653         brushes += self.groups[old_group]
654         self.delete_group(old_group)
655
656     def delete_group(self, group):
657         homeless_brushes = self.groups[group]
658         del self.groups[group]
659         if group in self.active_groups:
660             self.active_groups.remove(group)
661
662         for brushes in self.groups.itervalues():
663             for b2 in brushes:
664                 if b2 in homeless_brushes:
665                     homeless_brushes.remove(b2)
666
667         if homeless_brushes:
668             deleted_brushes = self.get_group_brushes(DELETED_BRUSH_GROUP)
669             for b in homeless_brushes:
670                 deleted_brushes.insert(0, b)
671             for f in self.brushes_observers: f(deleted_brushes)
672         for f in self.brushes_observers: f(homeless_brushes)
673         for f in self.groups_observers: f()
674         self.save_brushorder()
675
676
677 class ManagedBrush(object):
678     '''Represents a brush, but cannot be selected or painted with directly.'''
679     def __init__(self, brushmanager, name=None, persistent=False):
680         self.bm = brushmanager
681         self._preview = None
682         self.name = name
683         self.brushinfo = BrushInfo()
684         self.persistent = persistent #: If True this brush is stored in the filesystem.
685         self.settings_loaded = False  #: If True this brush is fully initialized, ready to paint with.
686         self.in_brushlist = False  #: Set to True if this brush is known to be in the brushlist
687
688         self.settings_mtime = None
689         self.preview_mtime = None
690
691         if persistent:
692             # we load the files later, but throw an exception now if they don't exist
693             self.get_fileprefix()
694
695     # load preview pixbuf on demand
696     def get_preview(self):
697         if self._preview is None and self.name:
698             self._load_preview()
699         if self._preview is None:
700             self.preview = gdk.Pixbuf(gdk.COLORSPACE_RGB, False, 8, preview_w, preview_h)
701             self.preview.fill(0xffffffff) # white
702         return self._preview
703     def set_preview(self, pixbuf):
704         self._preview = pixbuf
705     preview = property(get_preview, set_preview)
706
707     def get_display_name(self):
708         """Gets a displayable name for the brush.
709         """
710         if self.in_brushlist:
711             dname = self.name
712         else:
713             if self.persistent and not self.settings_loaded:   # XXX refactor
714                 self.load_settings()
715             dname = self.brushinfo.get_string_property("parent_brush_name")
716         if dname is None:
717             return _("Unknown Brush")
718         return dname.replace("_", " ")
719
720
721     def get_fileprefix(self, saving=False):
722         prefix = 'b'
723         if os.path.realpath(self.bm.user_brushpath) == os.path.realpath(self.bm.stock_brushpath):
724             # working directly on brush collection, use different prefix
725             prefix = 's'
726
727         if not self.name:
728             i = 0
729             while 1:
730                 self.name = u'%s%03d' % (prefix, i)
731                 a = os.path.join(self.bm.user_brushpath, self.name + '.myb')
732                 b = os.path.join(self.bm.stock_brushpath, self.name + '.myb')
733                 if not os.path.isfile(a) and not os.path.isfile(b):
734                     break
735                 i += 1
736         assert isinstance(self.name, unicode)
737         prefix = os.path.join(self.bm.user_brushpath, self.name)
738         if saving: 
739             if '/' in self.name:
740                 d = os.path.dirname(prefix)
741                 if not os.path.isdir(d):
742                     os.makedirs(d)
743             return prefix
744         if not os.path.isfile(prefix + '.myb'):
745             prefix = os.path.join(self.bm.stock_brushpath, self.name)
746         if not os.path.isfile(prefix + '.myb'):
747             raise IOError, 'brush "' + self.name + '" not found'
748         return prefix
749
750     def clone(self, name):
751         "Creates a new brush with all the settings of this brush, assigning it a new name"
752         clone = ManagedBrush(self.bm)
753         self.clone_into(clone, name=name)
754         return clone
755
756     def clone_into(self, target, name):
757         "Copies all brush settings into another brush, giving it a new name"
758         if not self.settings_loaded:   # XXX refactor
759             self.load()
760         target.brushinfo = self.brushinfo.clone()
761         if self.in_brushlist:
762             target.brushinfo.set_string_property("parent_brush_name", self.name)
763         target.preview = self.preview
764         target.name = name
765
766     def delete_from_disk(self):
767         prefix = os.path.join(self.bm.user_brushpath, self.name)
768         if os.path.isfile(prefix + '.myb'):
769             os.remove(prefix + '_prev.png')
770             os.remove(prefix + '.myb')
771             try:
772                 self.load()
773             except IOError:
774                 return True # success
775             else:
776                 return False # partial success, this brush was hiding a stock brush with the same name
777         # stock brush cannot be deleted
778         return False
779
780     def remember_mtimes(self):
781         prefix = self.get_fileprefix()
782         self.preview_mtime = os.path.getmtime(prefix + '_prev.png')
783         self.settings_mtime = os.path.getmtime(prefix + '.myb')
784
785     def has_changed_on_disk(self):
786         prefix = self.get_fileprefix()
787         if self.preview_mtime != os.path.getmtime(prefix + '_prev.png'): return True
788         if self.settings_mtime != os.path.getmtime(prefix + '.myb'): return True
789         return False
790
791     def save(self):
792         prefix = self.get_fileprefix(saving=True)
793         self.preview.save(prefix + '_prev.png', 'png')
794         brushinfo = self.brushinfo.clone()
795         open(prefix + '.myb', 'w').write(brushinfo.save_to_string())
796         self.remember_mtimes()
797
798     def load(self, retain_parent=False):
799         """Loads the brush's preview and settings from disk."""
800         if self.name is None:
801             warn("Attempt to load an unnamed brush, don't do that.",
802                  RuntimeWarning, 2)
803             return
804         self._load_preview()
805         self.load_settings(retain_parent)
806
807     def _load_preview(self):
808         """Loads the brush preview as pixbuf into the brush."""
809         assert self.name
810         prefix = self.get_fileprefix()
811
812         filename = prefix + '_prev.png'
813         pixbuf = gdk.pixbuf_new_from_file(filename)
814         self._preview = pixbuf
815         self.remember_mtimes()
816
817     def load_settings(self, retain_parent=False):
818         """Loads the brush settings/dynamics from disk."""
819         prefix = self.get_fileprefix()
820         filename = prefix + '.myb'
821         brushinfo_str = open(filename).read()
822         self.brushinfo.load_from_string(brushinfo_str)
823         self.remember_mtimes()
824         self.settings_loaded = True
825         if not retain_parent:
826             self.brushinfo.set_string_property("parent_brush_name", None)
827         self.persistent = True
828
829     def reload_if_changed(self):
830         if self.settings_mtime is None: return
831         if self.preview_mtime is None: return
832         if not self.name: return
833         if not self.has_changed_on_disk(): return False
834         print 'Brush "' + self.name + '" has changed on disk, reloading it.'
835         self.load()
836         return True
837
838     def __str__(self):
839         if self.brushinfo.settings:
840             return "<ManagedBrush %s p=%s>" % (self.name, self.brushinfo.get_string_property("parent_brush_name"))
841         else:
842             return "<ManagedBrush %s (settings not loaded yet)>" % self.name
843
844
845 if __name__ == '__main__':
846     import doctest
847     doctest.testmod()
848