1# This file is part of MyPaint.
2# -*- coding: utf-8 -*-
3# Copyright (C) 2009-2013 by Martin Renold <martinxyz@gmx.ch>
4# Copyright (C) 2010-2018 by the MyPaint Development Team.
5#
6# This program is free software; you can redistribute it and/or modify
7# it under the terms of the GNU General Public License as published by
8# the Free Software Foundation; either version 2 of the License, or
9# (at your option) any later version.
10
11"""File management for brushes and brush groups."""
12
13## Imports
14
15from __future__ import division, print_function
16
17from itertools import chain
18import os
19import zipfile
20from os.path import basename
21from warnings import warn
22import logging
23import shutil
24import uuid
25import contextlib
26
27from lib.gettext import gettext as _
28from lib.gettext import C_
29from lib.helpers import utf8
30
31from lib.gibindings import Gtk
32from lib.gibindings import GdkPixbuf
33
34from . import dialogs
35from lib.brush import BrushInfo
36from lib.observable import event
37import lib.pixbuf
38from . import drawutils
39import gui.mode
40import lib.config
41from lib.pycompat import unicode
42from lib.pycompat import xrange
43from lib.pycompat import PY3
44
45if PY3:
46    import urllib.parse
47else:
48    import urllib
49
50
51## Public module constants
52
53PREVIEW_W = 128   #: Width of brush preview images
54PREVIEW_H = 128   #: Height of brush preview images
55
56FOUND_BRUSHES_GROUP = 'lost&found'   #: Orphaned brushes found at startup
57DELETED_BRUSH_GROUP = 'deleted'   #: Orphaned brushes go here after group del
58FAVORITES_BRUSH_GROUP = u'favorites'  #: User's favourites
59NEW_BRUSH_GROUP = 'new'  #: Home for newly created brushes
60
61## Internal module constants
62
63_DEFAULT_STARTUP_GROUP = 'set#2'  # Suggestion only (FIXME: no effect?)
64_DEFAULT_BRUSH = 'Dieterle/Fan#1'  # TODO: phase out and use heuristics?
65_DEFAULT_ERASER = 'deevad/kneaded_eraser_large'  # TODO: -----------"---------
66_DEVBRUSH_NAME_PREFIX = "devbrush_"
67_BRUSH_HISTORY_NAME_PREFIX = "history_"
68_BRUSH_HISTORY_SIZE = 5
69_NUM_BRUSHKEYS = 10
70
71_BRUSHPACK_README = "readme.txt"
72_BRUSHPACK_ORDERCONF = "order.conf"
73
74_DEVICE_NAME_NAMESPACE = uuid.UUID('169eaf8a-554e-45b8-8295-fc09b10031cc')
75
76_TEST_BRUSHPACK_PY27 = u"tests/brushpacks/saved-with-py2.7.zip"
77
78logger = logging.getLogger(__name__)
79
80
81## Helper functions
82
83def _device_name_uuid(device_name):
84    """Return UUID5 string for a given device name
85
86    >>> result = _device_name_uuid(u'Wacom Intuos5 touch S Pen stylus')
87    >>> result == u'e97830e9-f9f9-50a5-8fff-68bead1a7021'
88    True
89    >>> type(result) == type(u'')
90    True
91
92    """
93    if not PY3:
94        device_name = utf8(unicode(device_name))
95    return unicode(uuid.uuid5(_DEVICE_NAME_NAMESPACE, device_name))
96
97
98def _quote_device_name(device_name):
99    """Converts a device name to something safely storable on the disk
100
101    Quotes an arbitrary device name for use as the basename of a
102    device-specific brush.
103
104    >>> result = _quote_device_name(u'Heavy Metal Umlaut D\u00ebvice')
105    >>> result == 'Heavy+Metal+Umlaut+D%C3%ABvice'
106    True
107    >>> type(result) == type(u'')
108    True
109    >>> result = _quote_device_name(u'unsafe/device\\\\name')
110    >>> result == 'unsafe%2Fdevice%5Cname'
111    True
112    >>> type(result) == type(u'')
113    True
114
115    Hopefully this is OK for Windows, UNIX and Mac OS X names.
116    """
117    device_name = unicode(device_name)
118    if PY3:
119        quoted = urllib.parse.quote_plus(
120            device_name, safe='',
121            encoding="utf-8",
122        )
123    else:
124        u8bytes = device_name.encode("utf-8")
125        quoted = urllib.quote_plus(u8bytes, safe='')
126    return unicode(quoted)
127
128
129def translate_group_name(name):
130    """Translates a group name from a disk name to a display name."""
131    d = {FOUND_BRUSHES_GROUP: _('Lost & Found'),
132         DELETED_BRUSH_GROUP: _('Deleted'),
133         FAVORITES_BRUSH_GROUP: _('Favorites'),
134         'ink': _('Ink'),
135         'classic': _('Classic'),
136         'set#1': _('Set#1'),
137         'set#2': _('Set#2'),
138         'set#3': _('Set#3'),
139         'set#4': _('Set#4'),
140         'set#5': _('Set#5'),
141         'experimental': _('Experimental'),
142         'new': _('New'),
143         }
144    return d.get(name, name)
145
146
147def _parse_order_conf(file_content):
148    """Parse order.conf file data.
149
150    :param bytes file_content: data from an order.conf (encoded UTF-8)
151    :returns: a group dict
152
153    The returned dict is of the form "{u'group1' : [u'brush1',
154    u'brush2'], u'group2' : [u'brush3']}".
155
156    """
157    groups = {}
158    try:
159        file_content = file_content.decode("utf-8")
160    except UnicodeDecodeError:
161        # This handles order.conf files saved with the wrong encoding
162        # on Windows (encoding was previously not explicitly specified).
163        logger.warning("order.conf file not encoded with utf-8")
164        file_content = file_content.decode('latin-1')
165    curr_group = FOUND_BRUSHES_GROUP
166    lines = file_content.replace(u'\r', u'\n').split(u'\n')
167    for line in lines:
168        name = line.strip()
169        if name.startswith(u'#') or not name:
170            continue
171        if name.startswith(u'Group: '):
172            curr_group = name[7:]
173            if curr_group not in groups:
174                groups[curr_group] = []
175            continue
176        groups.setdefault(curr_group, [])
177        if name in groups[curr_group]:
178            logger.warning(
179                '%r: brush appears twice in the same group, ignored',
180                name,
181            )
182            continue
183        groups[curr_group].append(name)
184    return groups
185
186
187## Class definitions
188
189
190class BrushManager (object):
191    """Brush manager, responsible for groups of brushes."""
192
193    ## Initialization
194
195    def __init__(self, stock_brushpath, user_brushpath, app=None):
196        """Initialize, with paths and a ref to the main app.
197
198        :param unicode|str stock_brushpath: MyPaint install's RO brushes.
199        :param unicode|str user_brushpath: User-writable brush library.
200        :param gui.application.Application app: Main app (use None for test).
201
202        The user_brushpath folder will be created if it does not yet exist.
203
204        >>> from tempfile import mkdtemp
205        >>> from shutil import rmtree
206        >>> tmpdir = mkdtemp(u".brushes")
207        >>> bm = BrushManager(lib.config.mypaint_brushdir, tmpdir, app=None)
208        >>> len(bm.groups) > 0
209        True
210        >>> all([isinstance(k, unicode) for k in bm.groups.keys()])
211        True
212        >>> all([isinstance(v, list) for v in bm.groups.values()])
213        True
214        >>> rmtree(tmpdir)
215
216        """
217        super(BrushManager, self).__init__()
218
219        # Default pigment setting when not specified by the brush
220        self.pigment_by_default = None
221
222        self.stock_brushpath = stock_brushpath
223        self.user_brushpath = user_brushpath
224        self.app = app
225
226        #: The selected brush, as a ManagedBrush. Its settings are
227        #: automatically reflected into the working brush engine brush when
228        #: it changes.
229        self.selected_brush = None
230
231        self.groups = {}  #: Lists of ManagedBrushes, keyed by group name
232        self.contexts = []  # Brush keys, indexed by keycap digit number
233        self._brush_by_device = {}  # Device name to brush mapping.
234
235        #: Slot used elsewhere for storing the ManagedBrush corresponding to
236        #: the most recently saved or restored "context", a.k.a. brush key.
237        self.selected_context = None
238
239        if not os.path.isdir(self.user_brushpath):
240            os.mkdir(self.user_brushpath)
241        self._init_groups()
242
243        # Brush order saving when that changes.
244        self.brushes_changed += self._brushes_modified_cb
245
246        # Update the history at the end of each definite input stroke.
247        if app is not None:
248            app.doc.input_stroke_ended += self._input_stroke_ended_cb
249
250        # Make sure the user always gets a brush tool when they pick a brush
251        # preset.
252        self.brush_selected += self._brush_selected_cb
253
254    @classmethod
255    @contextlib.contextmanager
256    def _mock(cls):
257        """Context-managed mock BrushManager object for tests.
258
259        Brushes are imported from the shipped brushes subfolder,
260        and the user temp area is a temporary directory that's
261        cleaned up by the context manager.
262
263        Body yields (BrushManager_instance, tmp_dir_path).
264
265        Please ensure that there are no open files in the tmpdir after
266        use to that it can be rmtree()d. On Windows, that means closing
267        any zipfile.Zipfile()s you open, even for read.
268
269        """
270        from tempfile import mkdtemp
271        from shutil import rmtree
272
273        dist_brushes = lib.config.mypaint_brushdir
274        tmp_user_brushes = mkdtemp(suffix=u"_brushes")
275        try:
276            bm = cls(dist_brushes, tmp_user_brushes, app=None)
277            yield (bm, tmp_user_brushes)
278        finally:
279            rmtree(tmp_user_brushes)
280
281    def _load_brush(self, brush_cache, name, **kwargs):
282        """Load a ManagedBrush from disk by name, via a cache."""
283        if name not in brush_cache:
284            b = ManagedBrush(self, name, persistent=True, **kwargs)
285            brush_cache[name] = b
286        return brush_cache[name]
287
288    def _load_ordered_groups(self, brush_cache, filename):
289        try:
290            return self._load_ordered_groups_inner(brush_cache, filename)
291        except Exception:
292            logger.exception("Failed to load groups from %s" % filename)
293            return {}
294
295    def _load_ordered_groups_inner(self, brush_cache, filename):
296        """Load a groups dict from an order.conf file."""
297        groups = {}
298        if os.path.exists(filename):
299            with open(filename, "rb") as fp:
300                groups = _parse_order_conf(fp.read())
301            # replace brush names with ManagedBrush instances
302            for group, names in list(groups.items()):
303                brushes = []
304                for name in names:
305                    try:
306                        b = self._load_brush(brush_cache, name)
307                    except IOError as e:
308                        logger.warn('%r: %r (removed from group)', name, e)
309                        continue
310                    brushes.append(b)
311                groups[group] = brushes
312        return groups
313
314    def _init_ordered_groups(self, brush_cache):
315        """Initialize the ordered subset of available brush groups.
316
317        The ordered subset consists of those brushes which are listed in
318        the stock and user brush directories' `order.conf` files.  This
319        method safely merges upstream changes into the user's ordering.
320
321        """
322        join = os.path.join
323        base_order_conf = join(self.user_brushpath, 'order_default.conf')
324        our_order_conf = join(self.user_brushpath, 'order.conf')
325        their_order_conf = join(self.stock_brushpath, 'order.conf')
326
327        # Three-way-merge of brush groups (for upgrading)
328        base = self._load_ordered_groups(brush_cache, base_order_conf)
329        our = self._load_ordered_groups(brush_cache, our_order_conf)
330        their = self._load_ordered_groups(brush_cache, their_order_conf)
331
332        if not our:
333            # order.conf missing, restore stock order even
334            # if order_default.conf exists
335            base = {}
336
337        if base == their:
338            self.groups = our
339        else:
340            logger.info('Merging upstream brush changes into your collection.')
341            groups = set(base).union(our).union(their)
342            for group in groups:
343                # treat the non-existing groups as if empty
344                base_brushes = base.setdefault(group, [])
345                our_brushes = our.setdefault(group, [])
346                their_brushes = their.setdefault(group, [])
347                # add new brushes
348                insert_index = 0
349                for b in their_brushes:
350                    if b in our_brushes:
351                        insert_index = our_brushes.index(b) + 1
352                    else:
353                        if b not in base_brushes:
354                            our_brushes.insert(insert_index, b)
355                            insert_index += 1
356                # remove deleted brushes
357                for b in base_brushes:
358                    if b not in their_brushes and b in our_brushes:
359                        our_brushes.remove(b)
360                # remove empty groups (except for the favorites)
361                if not our_brushes and group != FAVORITES_BRUSH_GROUP:
362                    del our[group]
363            # finish
364            self.groups = our
365            self.save_brushorder()
366            shutil.copy(their_order_conf, base_order_conf)
367
368    def _list_brushes(self, path):
369        """Recursively list the brushes within a directory.
370
371        Return a list of brush names relative to path, using slashes
372        for subdirectories on all platforms.
373
374        """
375        path += '/'
376        result = []
377        assert isinstance(path, unicode)  # make sure we get unicode filenames
378        for name in os.listdir(path):
379            assert isinstance(name, unicode)
380            if name.endswith('.myb'):
381                result.append(name[:-4])
382            elif os.path.isdir(path + name):
383                for name2 in self._list_brushes(path + name):
384                    result.append(name + '/' + name2)
385        return result
386
387    def _init_unordered_groups(self, brush_cache):
388        """Initialize the unordered subset of available brushes+groups.
389
390        The unordered subset consists of all brushes that are not listed
391        in an `order.conf` file. It includes brushkey brushes,
392        per-device brushes, brushes in the painting history.
393
394        This method trawls the stock and user brush directories for
395        brushes which aren't listed in in an existing group, and adds
396        them to the Lost & Found group, creating it if necessary. It
397        should therefore be called after `_init_ordered_groups()`.
398
399        """
400        listbrushes = self._list_brushes
401        for name in (listbrushes(self.stock_brushpath)
402                     + listbrushes(self.user_brushpath)):
403            if name.startswith(_DEVBRUSH_NAME_PREFIX):
404                # Device brushes are lazy-loaded in fetch_brush_for_device()
405                continue
406
407            try:
408                b = self._load_brush(brush_cache, name)
409            except IOError as e:
410                logger.warn("%r: %r (ignored)", name, e)
411                continue
412            if name.startswith('context'):
413                i = int(name[-2:])
414                self.contexts[i] = b
415            elif name.startswith(_BRUSH_HISTORY_NAME_PREFIX):
416                i_str = name.replace(_BRUSH_HISTORY_NAME_PREFIX, '')
417                i = int(i_str)
418                if 0 <= i < _BRUSH_HISTORY_SIZE:
419                    self.history[i] = b
420                else:
421                    logger.warning(
422                        "Brush history item %s "
423                        "(entry %d): index outside of history range (0-%d)!",
424                        name, i,
425                        _BRUSH_HISTORY_SIZE - 1
426                    )
427            else:
428                if not self.is_in_brushlist(b):
429                    logger.info("Unassigned brush %r: assigning to %r",
430                                name, FOUND_BRUSHES_GROUP)
431                    brushes = self.groups.setdefault(FOUND_BRUSHES_GROUP, [])
432                    brushes.insert(0, b)
433
434    def _init_default_brushkeys_and_history(self):
435        """Assign sensible defaults for brushkeys and history.
436
437        Operates by filling in the gaps after `_init_unordered_groups()`
438        has had a chance to populate the two lists.
439
440        """
441
442        # Try the default startup group first.
443        default_group = self.groups.get(_DEFAULT_STARTUP_GROUP, None)
444
445        # Otherwise, use the biggest group to minimise the chance
446        # of repetition.
447        if default_group is None:
448            groups_by_len = sorted((len(g), n, g)
449                                   for n, g in self.groups.items())
450            _len, _name, default_group = groups_by_len[-1]
451
452        # Populate blank entries.
453        for i in xrange(_NUM_BRUSHKEYS):
454            if self.contexts[i] is None:
455                idx = (i + 9) % 10  # keyboard order
456                c_name = unicode('context%02d') % i
457                c = ManagedBrush(self, name=c_name, persistent=False)
458                group_idx = idx % len(default_group)
459                b = default_group[group_idx]
460                b.clone_into(c, c_name)
461                self.contexts[i] = c
462        for i in xrange(_BRUSH_HISTORY_SIZE):
463            if self.history[i] is None:
464                h_name = unicode('%s%d') % (_BRUSH_HISTORY_NAME_PREFIX, i)
465                h = ManagedBrush(self, name=h_name, persistent=False)
466                group_i = i % len(default_group)
467                b = default_group[group_i]
468                b.clone_into(h, h_name)
469                self.history[i] = h
470
471    def _init_groups(self):
472        """Initialize brush groups, loading them from disk."""
473
474        self.contexts = [None for i in xrange(_NUM_BRUSHKEYS)]
475        self.history = [None for i in xrange(_BRUSH_HISTORY_SIZE)]
476
477        brush_cache = {}
478        self._init_ordered_groups(brush_cache)
479        self._init_unordered_groups(brush_cache)
480        self._init_default_brushkeys_and_history()
481
482        # clean up legacy stuff
483        fn = os.path.join(self.user_brushpath, 'deleted.conf')
484        if os.path.exists(fn):
485            os.remove(fn)
486
487    ## Observable events
488
489    @event
490    def brushes_changed(self, brushes):
491        """Event: brushes changed (within their groups).
492
493        Each observer is called with the following args:
494
495        :param self: this BrushManager object
496        :param brushes: Affected brushes
497        :type brushes: list of ManagedBrushes
498
499        This event is used to notify about brush ordering changes or brushes
500        being moved between groups.
501        """
502
503    @event
504    def groups_changed(self):
505        """Event: brush groups changed (deleted, renamed, created)
506
507        Observer callbacks are invoked with no args (other than a ref to the
508        brushgroup).  This is used when the "set" of groups change, e.g. when a
509        group is renamed, deleted, or created.  It's invoked when self.groups
510        changes.
511        """
512
513    @event
514    def brush_selected(self, brush, info):
515        """Event: a different brush was selected.
516
517        Observer callbacks are invoked with the newly selected ManagedBrush and
518        its corresponding BrushInfo.
519        """
520
521    ## Initial and default brushes
522
523    def select_initial_brush(self):
524        """Select the initial brush using saved app preferences.
525        """
526        initial_brush = None
527        # If we recorded which devbrush was last in use, restore it and assume
528        # that most of the time the user will continue to work with the same
529        # brush and its settings.
530        app = self.app
531        if app is not None:
532            prefs = app.preferences
533            last_used_devbrush = prefs.get('devbrush.last_used')
534            initial_brush = self.fetch_brush_for_device(last_used_devbrush)
535            # Otherwise, initialise from the old selected_brush setting
536            if initial_brush is None:
537                last_active_name = prefs.get('brushmanager.selected_brush')
538                if last_active_name is not None:
539                    initial_brush = self.get_brush_by_name(last_active_name)
540        # Fallback
541        if initial_brush is None:
542            initial_brush = self.get_default_brush()
543        self.select_brush(initial_brush)
544
545    def _get_matching_brush(self, name=None, keywords=None,
546                            favored_group=_DEFAULT_STARTUP_GROUP,
547                            fallback_eraser=0.0):
548        """Gets a brush robustly by name, by partial name, or a default.
549
550        If a brush named `name` exists, use that. Otherwise search though all
551        groups, `favored_group` first, for brushes with any of `keywords`
552        in their name. If that fails, construct a new default brush and use
553        a given value for its 'eraser' property.
554        """
555        if name is not None:
556            brush = self.get_brush_by_name(name)
557            if brush is not None:
558                return brush
559        if keywords is not None:
560            group_names = sorted(self.groups.keys())
561            if favored_group in self.groups:
562                group_names.remove(favored_group)
563                group_names.insert(0, favored_group)
564            for group_name in group_names:
565                for brush in self.groups[group_name]:
566                    for keyword in keywords:
567                        if keyword in brush.name:
568                            return brush
569        # Fallback
570        name = 'fallback-default'
571        if fallback_eraser != 0.0:
572            name += '-eraser'
573        brush = ManagedBrush(self, name)
574        brush.brushinfo.set_base_value("eraser", fallback_eraser)
575        return brush
576
577    def get_default_brush(self):
578        """Returns a suitable default drawing brush."""
579        drawing = ["pencil", "charcoal", "sketch"]
580        return self._get_matching_brush(name=_DEFAULT_BRUSH, keywords=drawing)
581
582    def get_default_eraser(self):
583        """Returns a suitable default eraser brush."""
584        erasing = ["eraser", "kneaded", "smudge"]
585        return self._get_matching_brush(name=_DEFAULT_ERASER, keywords=erasing,
586                                        fallback_eraser=1.0)
587
588    def set_pigment_by_default(self, pigment_by_default):
589        """Change the default pigment setting to on/off
590
591        This updates loaded managed brushes as well, if they
592        do not have the pigment setting set explicitly.
593        """
594        if self.pigment_by_default != pigment_by_default:
595            msg = "Switching default pigment setting to {state}"
596            logger.info(msg.format(
597                state="On" if pigment_by_default else "Off"))
598            self.pigment_by_default = pigment_by_default
599            self._reset_pigment_setting()
600
601    def default_pigment_setting(self, setting_info):
602        """Pigment (paint_mode) setting override
603        """
604        if self.pigment_by_default:
605            return setting_info.default
606        else:
607            return setting_info.min
608
609    def _reset_pigment_setting(self):
610        appbrush = ()
611        if self.app:
612            appbrush = (self.app.brush,)
613        # Reset the pigment setting for any cached brushes
614        # that may have been loaded with the other default - this
615        # will not affect brushes that have the pigment setting
616        # defined explicitly.
617        to_reset = chain(
618            # Alter both the current working brush and the selected brush
619            appbrush,
620            (self.selected_brush.get_brushinfo(),),
621            # Also the brush history
622            [mb.get_brushinfo() for mb in self.history
623             if mb is not None and mb.loaded()],
624            # And any other loaded brush
625            [mb.get_brushinfo()
626             for v in self.groups.values()
627             for mb in v if mb.loaded()]
628        )
629        for bi in to_reset:
630            bi.reset_if_undefined('paint_mode')
631
632
633    ## Brushpack import and export
634
635    def import_brushpack(self, path, window=None):
636        """Import a brushpack from a zipfile, with confirmation dialogs.
637
638        :param path: Brush pack zipfile path
639        :type path: str
640        :param window: Parent window, for dialogs to set.
641        :type window: GtkWindow or None
642        :returns: Set of imported group names
643        :rtype: set
644
645        Confirmation dialogs are only shown if "window" is a suitable
646        toplevel to attach the dialogs to.
647
648        >>> with BrushManager._mock() as (bm, tmpdir):
649        ...     imp = bm.import_brushpack(_TEST_BRUSHPACK_PY27, window=None)
650        ...     py27_g = bm.groups.get(list(imp)[0])
651        >>> py27_g[0]  # doctest: +ELLIPSIS
652        <ManagedBrush...>
653        >>> g_names = set(b.name for b in py27_g)
654        >>> u'brushlib-test/basic' in g_names
655        True
656        >>> u'brushlib-test/fancy_\U0001f308\U0001f984\u2728' in g_names
657        True
658
659        """
660
661        with zipfile.ZipFile(path) as zf:
662
663            # In Py2, when the entry was saved without the 0x800 flag
664            # namelist() will return it as bytes, not unicode.  We only
665            # want Unicode strings.
666            names = []
667            for name in zf.namelist():
668                if isinstance(name, bytes):
669                    name = name.decode("utf-8")
670                names.append(name)
671
672            readme = None
673            if _BRUSHPACK_README in names:
674                readme = zf.read(_BRUSHPACK_README).decode("utf-8")
675
676            if _BRUSHPACK_ORDERCONF not in names:
677                raise InvalidBrushpack(C_(
678                    "brushpack import failure messages",
679                    u"No file named “{order_conf_file}”. "
680                    u"This is not a brushpack."
681                ).format(
682                    order_conf_file = _BRUSHPACK_ORDERCONF,
683                ))
684            groups = _parse_order_conf(zf.read(_BRUSHPACK_ORDERCONF))
685
686            new_brushes = []
687            for brushes in groups.values():
688                for brush in brushes:
689                    if brush not in new_brushes:
690                        new_brushes.append(brush)
691            logger.info(
692                "%d different brushes found in %r of brushpack",
693                len(new_brushes),
694                _BRUSHPACK_ORDERCONF,
695            )
696
697            # Validate file content. The names in order.conf and the
698            # brushes found in the zip must match. This should catch
699            # encoding screwups, everything should be a unicode object.
700            for brush in new_brushes:
701                if brush + '.myb' not in names:
702                    raise InvalidBrushpack(C_(
703                        "brushpack import failure messages",
704                        u"Brush “{brush_name}” is "
705                        u"listed in “{order_conf_file}”, "
706                        u"but it does not exist in the zipfile."
707                    ).format(
708                        brush_name = brush,
709                        order_conf_file = _BRUSHPACK_ORDERCONF,
710                    ))
711            for name in names:
712                if name.endswith('.myb'):
713                    brush = name[:-4]
714                    if brush not in new_brushes:
715                        raise InvalidBrushpack(C_(
716                            "brushpack import failure messages",
717                            u"Brush “{brush_name}” exists in the zipfile, "
718                            u"but it is not listed in “{order_conf_file}”."
719                        ).format(
720                            brush_name = brush,
721                            order_conf_file = _BRUSHPACK_ORDERCONF,
722                        ))
723            if readme and window:
724                answer = dialogs.confirm_brushpack_import(
725                    basename(path), window, readme,
726                )
727                if answer == Gtk.ResponseType.REJECT:
728                    return set()
729
730            do_overwrite = False
731            do_ask = True
732            renamed_brushes = {}
733            imported_groups = set()
734            for groupname, brushes in groups.items():
735                managed_brushes = self.get_group_brushes(groupname)
736                if managed_brushes:
737                    answer = dialogs.DONT_OVERWRITE_THIS
738                    if window:
739                        answer = dialogs.confirm_rewrite_group(
740                            window, translate_group_name(groupname),
741                            translate_group_name(DELETED_BRUSH_GROUP),
742                        )
743                    if answer == dialogs.CANCEL:
744                        return set()
745                    elif answer == dialogs.OVERWRITE_THIS:
746                        self.delete_group(groupname)
747                    elif answer == dialogs.DONT_OVERWRITE_THIS:
748                        i = 0
749                        old_groupname = groupname
750                        while groupname in self.groups:
751                            i += 1
752                            groupname = old_groupname + '#%d' % i
753                    managed_brushes = self.get_group_brushes(groupname)
754                imported_groups.add(groupname)
755
756                for brushname in brushes:
757                    # extract the brush from the zip
758                    assert (brushname + '.myb') in zf.namelist()
759                    # Support for utf-8 ZIP filenames that don't have
760                    # the utf-8 bit set.
761                    brushname_utf8 = utf8(brushname)
762                    try:
763                        myb_data = zf.read(brushname + u'.myb')
764                    except KeyError:
765                        myb_data = zf.read(brushname_utf8 + b'.myb')
766                    try:
767                        preview_data = zf.read(brushname + u'_prev.png')
768                    except KeyError:
769                        preview_data = zf.read(brushname_utf8 + b'_prev.png')
770                    # in case we have imported that brush already in a
771                    # previous group, but decided to rename it
772                    if brushname in renamed_brushes:
773                        brushname = renamed_brushes[brushname]
774                    # possibly ask how to import the brush file
775                    # (if we didn't already)
776                    b = self.get_brush_by_name(brushname)
777                    if brushname in new_brushes:
778                        new_brushes.remove(brushname)
779                        if b:
780                            existing_preview_pixbuf = b.preview
781                            if do_ask and window:
782                                answer = dialogs.confirm_rewrite_brush(
783                                    window, brushname, existing_preview_pixbuf,
784                                    preview_data,
785                                )
786                                if answer == dialogs.CANCEL:
787                                    break
788                                elif answer == dialogs.OVERWRITE_ALL:
789                                    do_overwrite = True
790                                    do_ask = False
791                                elif answer == dialogs.OVERWRITE_THIS:
792                                    do_overwrite = True
793                                    do_ask = True
794                                elif answer == dialogs.DONT_OVERWRITE_THIS:
795                                    do_overwrite = False
796                                    do_ask = True
797                                elif answer == dialogs.DONT_OVERWRITE_ANYTHING:
798                                    do_overwrite = False
799                                    do_ask = False
800                            # find a new name (if requested)
801                            brushname_old = brushname
802                            i = 0
803                            while not do_overwrite and b:
804                                i += 1
805                                brushname = brushname_old + u'#%d' % i
806                                renamed_brushes[brushname_old] = brushname
807                                b = self.get_brush_by_name(brushname)
808
809                        if not b:
810                            b = ManagedBrush(self, brushname)
811
812                        # write to disk and reload brush (if overwritten)
813                        prefix = b._get_fileprefix(saving=True)
814                        with open(prefix + '.myb', 'wb') as myb_f:
815                            myb_f.write(myb_data)
816                        with open(prefix + '_prev.png', 'wb') as preview_f:
817                            preview_f.write(preview_data)
818                        b.load()
819                    # finally, add it to the group
820                    if b not in managed_brushes:
821                        managed_brushes.append(b)
822                    self.brushes_changed(managed_brushes)
823
824        if DELETED_BRUSH_GROUP in self.groups:
825            # remove deleted brushes that are in some group again
826            self.delete_group(DELETED_BRUSH_GROUP)
827        return imported_groups
828
829    def export_group(self, group, filename):
830        """Exports a group to a brushpack zipfile.
831
832        :param unicode|str group: Name of the group to save.
833        :param unicode|str filename: Path to a .zip file to create.
834
835        >>> with BrushManager._mock() as (bm, tmpdir):
836        ...     group = list(bm.groups)[0]
837        ...     zipname = os.path.join(tmpdir, group + u"zip")
838        ...     bm.export_group(group, zipname)
839        ...     with zipfile.ZipFile(zipname, mode="r") as zf:
840        ...         assert len(zf.namelist()) > 0
841        ...         assert u"order.conf" in zf.namelist()
842
843        """
844        brushes = self.get_group_brushes(group)
845        order_conf = b'Group: %s\n' % utf8(group)
846        with zipfile.ZipFile(filename, mode='w') as zf:
847            for brush in brushes:
848                prefix = brush._get_fileprefix()
849                zf.write(prefix + u'.myb', brush.name + u'.myb')
850                zf.write(prefix + u'_prev.png', brush.name + u'_prev.png')
851                order_conf += utf8(brush.name + "\n")
852            zf.writestr(u'order.conf', order_conf)
853
854    ## Brush lookup / access
855
856    def get_brush_by_name(self, name):
857        """Gets a ManagedBrush by its name.
858
859        Slow method, should not be called too often.
860
861        >>> with BrushManager._mock() as (bm, tmpdir):
862        ...     brush1 = bm.get_brush_by_name(u"classic/pen")
863        >>> brush1 # doctest: +ELLIPSIS
864        <ManagedBrush...>
865
866        """
867        # FIXME: speed up, use a dict.
868        for group, brushes in self.groups.items():
869            for b in brushes:
870                if b.name == name:
871                    return b
872
873    def is_in_brushlist(self, brush):
874        """Returns whether this brush is in some brush group's list."""
875        for group, brushes in self.groups.items():
876            if brush in brushes:
877                return True
878        return False
879
880    def get_parent_brush(self, brush=None, brushinfo=None):
881        """Gets the parent `ManagedBrush` for a brush or a `BrushInfo`.
882        """
883        if brush is not None:
884            brushinfo = brush.brushinfo
885        if brushinfo is None:
886            raise RuntimeError("One of `brush` or `brushinfo` must be defined")
887        parent_name = brushinfo.get_string_property("parent_brush_name")
888        if parent_name is None:
889            return None
890        else:
891            parent_brush = self.get_brush_by_name(parent_name)
892            if parent_brush is None:
893                return None
894            return parent_brush
895
896    ## Brush order within groups, order.conf
897
898    def _brushes_modified_cb(self, bm, brushes):
899        """Saves the brush order when it changes."""
900        self.save_brushorder()
901
902    def save_brushorder(self):
903        """Save the user's chosen brush order to disk.
904
905        >>> with BrushManager._mock() as (bm, tmpdir):
906        ...     bm.save_brushorder()
907
908        """
909
910        path = os.path.join(self.user_brushpath, u'order.conf')
911        with open(path, 'wb') as f:
912            f.write(utf8(u'# this file saves brush groups and order\n'))
913            for group, brushes in self.groups.items():
914                f.write(utf8(u'Group: {}\n'.format(group)))
915                for b in brushes:
916                    f.write(utf8(b.name + u'\n'))
917
918    ## The selected brush
919
920    def select_brush(self, brush):
921        """Selects a ManagedBrush, highlights it, & updates the live brush.
922
923        :param brush: brush to select
924        :type brush: BrushInfo
925
926        """
927        if brush is None:
928            brush = self.get_default_brush()
929
930        brushinfo = brush.brushinfo
931        if not self.is_in_brushlist(brush):
932            # select parent brush instead, but keep brushinfo
933            parent = self.get_parent_brush(brush=brush)
934            if parent is not None:
935                brush = parent
936
937        self.selected_brush = brush
938        if self.app is not None:
939            self.app.preferences['brushmanager.selected_brush'] = brush.name
940
941        # Notify subscribers. Takes care of updating the live
942        # brush, amongst other things
943        self.brush_selected(brush, brushinfo)
944
945    def clone_selected_brush(self, name):
946        """Clones the current and selected brush into a new `BrushInfo`.
947
948        Creates a new ManagedBrush based on the selected brush in the brushlist
949        and the currently active lib.brush. The brush settings are copied from
950        the active brush, and the preview is copied from the currently selected
951        BrushInfo.
952
953        """
954        if self.app is None:
955            raise ValueError("No app. BrushManager in test mode?")
956        clone = ManagedBrush(self, name, persistent=False)
957        clone.brushinfo = self.app.brush.clone()
958        clone.preview = self.selected_brush.preview
959        parent = self.selected_brush.name
960        clone.brushinfo.set_string_property("parent_brush_name", parent)
961        return clone
962
963    def _brush_selected_cb(self, bm, brush, brushinfo):
964        """Internal callback: User just picked a brush preset.
965
966        Called when the user changes to a brush preset somehow (e.g.
967        from a shortcut or the brush panel). Makes sure a
968        brush-dependant tool (e.g. Freehand, Connected Lines, etc.) is
969        selected.
970
971        """
972        if not self.app:
973            return
974        self.app.doc.modes.pop_to_behaviour(gui.mode.Behavior.PAINT_BRUSH)
975
976    ## Device-specific brushes
977
978    def store_brush_for_device(self, device_name, managed_brush):
979        """Records a brush as associated with an input device.
980
981        :param device_name: name of an input device
982        :type device_name: str
983        :param managed_brush: the brush to associate
984        :type managed_brush: ManagedBrush
985
986        Normally the brush will be cloned first, since it will be given a new
987        name. However, if the brush has a 'name' attribute of None, it will
988        *not* be cloned and just modified in place and stored.
989
990        """
991        brush = managed_brush
992        if brush.name is not None:
993            brush = brush.clone()
994        brush.name = unicode(
995            _DEVBRUSH_NAME_PREFIX + _device_name_uuid(device_name))
996        self._brush_by_device[device_name] = brush
997
998    def fetch_brush_for_device(self, device_name):
999        """Fetches the brush associated with an input device."""
1000        if not device_name:
1001            return None
1002
1003        if device_name not in self._brush_by_device:
1004            self._brush_by_device[device_name] = None
1005
1006            names = (
1007                _device_name_uuid(device_name),
1008                _quote_device_name(device_name),  # for backward compatibility
1009            )
1010            for name in names:
1011                path = os.path.join(
1012                    self.user_brushpath, _DEVBRUSH_NAME_PREFIX + name + '.myb')
1013                if not os.path.isfile(path):
1014                    continue
1015
1016                try:
1017                    b = ManagedBrush(
1018                        self, unicode(_DEVBRUSH_NAME_PREFIX + name),
1019                        persistent=True)
1020                except IOError as e:
1021                    logger.warn("%r: %r (ignored)", name, e)
1022                else:
1023                    self._brush_by_device[device_name] = b
1024
1025                break
1026
1027        assert device_name in self._brush_by_device
1028        return self._brush_by_device[device_name]
1029
1030    def save_brushes_for_devices(self):
1031        """Saves the device/brush associations to disk."""
1032        for devbrush in self._brush_by_device.values():
1033            if devbrush is not None:
1034                devbrush.save()
1035
1036    ## Brush history
1037
1038    def _input_stroke_ended_cb(self, doc, event):
1039        """Update brush usage history at the end of an input stroke."""
1040        if self.app is None:
1041            raise ValueError("No app. BrushManager in test mode?")
1042        wb_info = self.app.brush
1043        wb_parent_name = wb_info.settings.get("parent_brush_name")
1044        # Remove the to-be-added brush from the history if it's already in it
1045        if wb_parent_name:
1046            # Favour "same parent" as the main measure of identity,
1047            # when it's defined.
1048            for i, hb in enumerate(self.history):
1049                hb_info = hb.brushinfo
1050                hb_parent_name = hb_info.settings.get("parent_brush_name")
1051                if wb_parent_name == hb_parent_name:
1052                    del self.history[i]
1053                    break
1054        else:
1055            # Otherwise, fall back to matching on the brush dynamics.
1056            # Many old .ORA files have pickable strokes in their layer map
1057            # which don't nominate a parent.
1058            for i, hb in enumerate(self.history):
1059                hb_info = hb.brushinfo
1060                if wb_info.matches(hb_info):
1061                    del self.history[i]
1062                    break
1063        # Append the working brush to the history, and trim it to length
1064        nb = ManagedBrush(self, name=None, persistent=False)
1065        nb.brushinfo = wb_info.clone()
1066        nb.preview = self.selected_brush.preview
1067        self.history.append(nb)
1068        while len(self.history) > _BRUSH_HISTORY_SIZE:
1069            del self.history[0]
1070        # Rename the history brushes so they save to the right files.
1071        for i, hb in enumerate(self.history):
1072            hb.name = u"%s%d" % (_BRUSH_HISTORY_NAME_PREFIX, i)
1073
1074    def save_brush_history(self):
1075        """Saves the brush usage history to disk."""
1076        for brush in self.history:
1077            brush.save()
1078
1079    ## Brush groups
1080
1081    def get_group_brushes(self, group):
1082        """Get a group's active brush list.
1083
1084        If the group does not exist, it will be created.
1085
1086        :param str group: Name of the group to fetch
1087        :returns: The active list of `ManagedBrush`es.
1088        :rtype: list
1089
1090        The returned list is owned by the BrushManager. You can modify
1091        it, but you'll have to do your own notifications.
1092
1093        See also: groups_changed(), brushes_changed().
1094
1095        """
1096        if group not in self.groups:
1097            brushes = []
1098            self.groups[group] = brushes
1099            self.groups_changed()
1100            self.save_brushorder()
1101        return self.groups[group]
1102
1103    def create_group(self, new_group):
1104        """Creates a new brush group
1105
1106        :param group: Name of the group to create
1107        :type group: str
1108        :rtype: empty list, owned by the BrushManager
1109
1110        Returns the newly created group as a(n empty) list.
1111
1112        """
1113        return self.get_group_brushes(new_group)
1114
1115    def rename_group(self, old_group, new_group):
1116        """Renames a group.
1117
1118        :param old_group: Name of the group to assign the new name to.
1119        :type old_group: str
1120        :param new_group: New name for the group.
1121        :type new_group: str
1122
1123        """
1124        brushes = self.create_group(new_group)
1125        brushes += self.groups[old_group]
1126        self.delete_group(old_group)
1127
1128    def delete_group(self, group):
1129        """Deletes a group.
1130
1131        :param group: Name of the group to delete
1132        :type group: str
1133
1134        Orphaned brushes will be placed into `DELETED_BRUSH_GROUP`, which
1135        will be created if necessary.
1136
1137        """
1138
1139        homeless_brushes = self.groups[group]
1140        del self.groups[group]
1141
1142        for brushes in self.groups.values():
1143            for b2 in brushes:
1144                if b2 in homeless_brushes:
1145                    homeless_brushes.remove(b2)
1146
1147        if homeless_brushes:
1148            deleted_brushes = self.get_group_brushes(DELETED_BRUSH_GROUP)
1149            for b in homeless_brushes:
1150                deleted_brushes.insert(0, b)
1151            self.brushes_changed(deleted_brushes)
1152        self.brushes_changed(homeless_brushes)
1153        self.groups_changed()
1154        self.save_brushorder()
1155
1156
1157class ManagedBrush(object):
1158    """User-facing representation of a brush's settings.
1159
1160    Managed brushes have a name, a preview image, and brush settings.
1161    The settings and the preview are loaded on demand.
1162    They cannot be selected or painted with directly,
1163    but their settings can be loaded into the running app:
1164    see `Brushmanager.select_brush()`.
1165
1166    """
1167
1168    def __init__(self, brushmanager, name=None, persistent=False):
1169        """Construct, with a ref back to its BrushManager.
1170
1171        Normally clients won't construct ManagedBrushes directly.
1172        Instead, use the groups dict in the BrushManager for access to
1173        all the brushes loaded from the user and stock brush folders.
1174
1175        >>> with BrushManager._mock() as (bm, tmpdir):
1176        ...     for gname, gbrushes in bm.groups.items():
1177        ...         for b in gbrushes:
1178        ...             assert isinstance(b, ManagedBrush)
1179        ...             b.load()
1180
1181        """
1182
1183        super(ManagedBrush, self).__init__()
1184        self.bm = brushmanager
1185        self._preview = None
1186        self._brushinfo = BrushInfo(default_overrides={
1187            'paint_mode': self.bm.default_pigment_setting
1188        })
1189
1190        #: The brush's relative filename, sans extension.
1191        self.name = name
1192
1193        #: If True, this brush is stored in the filesystem.
1194        self.persistent = persistent
1195
1196        # If True, this brush is fully initialized, ready to paint with.
1197        self._settings_loaded = False
1198
1199        # Change detection for on-disk files.
1200        self._settings_mtime = None
1201        self._preview_mtime = None
1202
1203        # Files are loaded later,
1204        # but throw an exception now if they don't exist.
1205        if persistent:
1206            self._get_fileprefix()
1207            assert self.name is not None
1208
1209    def loaded(self):
1210        return self._settings_loaded
1211
1212    ## Preview image: loaded on demand
1213
1214    def get_preview(self):
1215        """Gets a preview image for the brush
1216
1217        For persistent brushes, this loads the disk preview; otherwise a
1218        fairly slow automated brush preview is used.
1219
1220        >>> with BrushManager._mock() as (bm, tmpdir):
1221        ...     b = ManagedBrush(bm, name=None, persistent=False)
1222        ...     b.get_preview()   # doctest: +ELLIPSIS
1223        <GdkPixbuf.Pixbuf...>
1224
1225        The results are cached in RAM.
1226
1227        >>> with BrushManager._mock() as (bm, tmpdir):
1228        ...     imported = bm.import_brushpack(_TEST_BRUSHPACK_PY27)
1229        ...     assert(imported)
1230        ...     pixbufs1 = []
1231        ...     for gn in sorted(bm.groups.keys()):
1232        ...         gbs = bm.groups[gn]
1233        ...         for b in gbs:
1234        ...             pixbufs1.append(b)
1235        ...     pixbufs2 = []
1236        ...     for gn in sorted(bm.groups.keys()):
1237        ...         gbs = bm.groups[gn]
1238        ...         for b in gbs:
1239        ...             pixbufs2.append(b)
1240        >>> len(pixbufs1) == len(pixbufs2)
1241        True
1242        >>> all([p1 is p2 for (p1, p2) in zip(pixbufs1, pixbufs2)])
1243        True
1244        >>> pixbufs1 == pixbufs2
1245        True
1246
1247        """
1248        if self._preview is None and self.name:
1249            self._load_preview()
1250        if self._preview is None:
1251            brushinfo = self.get_brushinfo()
1252            self._preview = drawutils.render_brush_preview_pixbuf(brushinfo)
1253        return self._preview
1254
1255    def set_preview(self, pixbuf):
1256        self._preview = pixbuf
1257
1258    preview = property(get_preview, set_preview)
1259
1260    ## Text fields
1261
1262    @property
1263    def description(self):
1264        """Short, user-facing tooltip description for the brush.
1265
1266        >>> with BrushManager._mock() as (bm, tmpdir):
1267        ...     for gn, gbs in bm.groups.items():
1268        ...         for b in gbs:
1269        ...             assert isinstance(b.description, unicode)
1270        ...             b.description = u"junk"
1271        ...             assert isinstance(b.description, unicode)
1272        ...             b.save()
1273
1274        """
1275        return self.brushinfo.get_string_property("description")
1276
1277    @description.setter
1278    def description(self, s):
1279        self.brushinfo.set_string_property("description", s)
1280
1281    @property
1282    def notes(self):
1283        """Longer, brush developer's notes field for a brush.
1284
1285        >>> with BrushManager._mock() as (bm, tmpdir):
1286        ...     imp = bm.import_brushpack(_TEST_BRUSHPACK_PY27)
1287        ...     imp_g = list(imp)[0]
1288        ...     for b in bm.groups[imp_g]:
1289        ...         assert isinstance(b.notes, unicode)
1290        ...         b.notes = u"junk note"
1291        ...         assert isinstance(b.notes, unicode)
1292        ...         b.save()
1293
1294        """
1295        return self.brushinfo.get_string_property("notes")
1296
1297    @notes.setter
1298    def notes(self, s):
1299        self.brushinfo.set_string_property("notes", s)
1300
1301    ## Brush settings: loaded on demand
1302
1303    def get_brushinfo(self):
1304        self._ensure_settings_loaded()
1305        return self._brushinfo
1306
1307    def set_brushinfo(self, brushinfo):
1308        self._brushinfo = brushinfo
1309
1310    brushinfo = property(get_brushinfo, set_brushinfo)
1311
1312    ## Display
1313
1314    def __repr__(self):
1315        if self._brushinfo.settings:
1316            pname = self._brushinfo.get_string_property("parent_brush_name")
1317            return "<ManagedBrush %r p=%s>" % (self.name, pname)
1318        else:
1319            return "<ManagedBrush %r (settings not loaded yet)>" % self.name
1320
1321    def get_display_name(self):
1322        """Gets a displayable name for the brush."""
1323        if self.bm.is_in_brushlist(self):  # FIXME: get rid of this check
1324            dname = self.name
1325        else:
1326            dname = self.brushinfo.get_string_property("parent_brush_name")
1327        if dname is None:
1328            return _("Unknown Brush")
1329        return dname.replace("_", " ")
1330
1331    ## Cloning
1332
1333    def clone(self, name):
1334        """Clone this brush, and give it a new name.
1335
1336        Creates a new brush with all the settings of this brush,
1337        assigning it a new name
1338
1339        """
1340        clone = ManagedBrush(self.bm)
1341        self.clone_into(clone, name=name)
1342        return clone
1343
1344    def clone_into(self, target, name):
1345        "Copies all brush settings into another brush, giving it a new name"
1346        self._ensure_settings_loaded()
1347        target.brushinfo = self.brushinfo.clone()
1348        if self.bm.is_in_brushlist(self):  # FIXME: get rid of this check!
1349            target.brushinfo.set_string_property(
1350                "parent_brush_name", self.name,
1351            )
1352        target.preview = self.preview
1353        target.name = name
1354
1355    ## File save/load helpers
1356
1357    def _get_fileprefix(self, saving=False):
1358        """Returns the filesystem prefix to use when saving or loading.
1359
1360        :param saving: caller wants a prefix to save to
1361        :type saving: bool
1362        :rtype: unicode
1363
1364        This assigns ``self.name`` if it isn't defined.
1365
1366        Files are stored with the returned prefix,
1367        with the extension ".myb" for brush data
1368        and "_prev.myb" for preview images.
1369
1370        If `saving` is true, intermediate directories will be created,
1371        and the returned prefix will always contain the user brushpath.
1372        Otherwise the prefix you get depends on
1373        whether a stock brush exists and
1374        whether a user brush with the same name does not.
1375
1376        See also `delete_from_disk()`.
1377
1378        """
1379        prefix = u'b'
1380        user_bp = os.path.realpath(self.bm.user_brushpath)
1381        stock_bp = os.path.realpath(self.bm.stock_brushpath)
1382        if user_bp == stock_bp:
1383            # working directly on brush collection, use different prefix
1384            prefix = u's'
1385
1386        # Construct a new, unique name if the brush is not yet named
1387        if not self.name:
1388            i = 0
1389            while True:
1390                self.name = u'%s%03d' % (prefix, i)
1391                a = os.path.join(self.bm.user_brushpath, self.name + u'.myb')
1392                b = os.path.join(self.bm.stock_brushpath, self.name + u'.myb')
1393                if not os.path.isfile(a) and not os.path.isfile(b):
1394                    break
1395                i += 1
1396        assert isinstance(self.name, unicode)
1397
1398        # Always save to the user brush path.
1399        prefix = os.path.join(self.bm.user_brushpath, self.name)
1400        if saving:
1401            if u'/' in self.name:
1402                d = os.path.dirname(prefix)
1403                if not os.path.isdir(d):
1404                    os.makedirs(d)
1405            return prefix
1406
1407        # Loading: try user first, then stock
1408        if not os.path.isfile(prefix + u'.myb'):
1409            prefix = os.path.join(self.bm.stock_brushpath, self.name)
1410        if not os.path.isfile(prefix + u'.myb'):
1411            raise IOError('brush "%s" not found' % self.name)
1412        return prefix
1413
1414    def _remember_mtimes(self):
1415        prefix = self._get_fileprefix()
1416        try:
1417            preview_file = prefix + '_prev.png'
1418            self._preview_mtime = os.path.getmtime(preview_file)
1419        except OSError:
1420            logger.exception("Failed to update preview file access time")
1421            self._preview_mtime = None
1422        try:
1423            settings_file = prefix + '.myb'
1424            self._settings_mtime = os.path.getmtime(settings_file)
1425        except OSError:
1426            logger.exception("Failed to update settings file access time")
1427            self._settings_mtime = None
1428
1429    ## Saving and deleting
1430
1431    def save(self):
1432        """Saves the brush's settings and its preview"""
1433        prefix = self._get_fileprefix(saving=True)
1434        # Save preview
1435        if self.preview.get_has_alpha():
1436            # Remove alpha
1437            # Previous mypaint versions would display an empty image
1438            w, h = PREVIEW_W, PREVIEW_H
1439            tmp = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, False,
1440                                       8, w, h)
1441            tmp.fill(0xffffffff)
1442            self.preview.composite(tmp, 0, 0, w, h, 0, 0, 1, 1,
1443                                   GdkPixbuf.InterpType.BILINEAR, 255)
1444            self.preview = tmp
1445        preview_filename = prefix + '_prev.png'
1446        logger.debug("Saving brush preview to %r", preview_filename)
1447        lib.pixbuf.save(self.preview, preview_filename, "png")
1448        # Save brush settings
1449        brushinfo = self.brushinfo.clone()
1450        settings_filename = prefix + '.myb'
1451        logger.debug("Saving brush settings to %r", settings_filename)
1452        with open(settings_filename, 'w') as settings_fp:
1453            settings_fp.write(brushinfo.save_to_string())
1454        # Record metadata
1455        self._remember_mtimes()
1456
1457    def delete_from_disk(self):
1458        """Tries to delete the files for this brush from disk.
1459
1460        :rtype: bool
1461
1462        Returns True if the disk files can no longer be loaded. Stock brushes
1463        cannot be deleted, but if a user brush is hiding a stock brush with the
1464        same name, then although this method will remove the files describing
1465        the user brush, the stock brush is left intact. In this case, False is
1466        returned (because a load() attempt will now load the stock brush - and
1467        in fact has just done so).
1468
1469        """
1470
1471        prefix = os.path.join(self.bm.user_brushpath, self.name)
1472        if os.path.isfile(prefix + '.myb'):
1473            os.remove(prefix + '_prev.png')
1474            os.remove(prefix + '.myb')
1475            try:
1476                self.load()
1477            except IOError:
1478                # Files are no longer there, and no stock files with the
1479                # same name could be loaded.
1480                return True
1481            else:
1482                # User brush was hiding a stock brush with the same name.
1483                return False
1484        # Stock brushes cannot be deleted.
1485        return False
1486
1487    ## Loading and reloading
1488
1489    def load(self):
1490        """Loads the brush's preview and settings from disk."""
1491        if self.name is None:
1492            warn("Attempt to load an unnamed brush, don't do that.",
1493                 RuntimeWarning, 2)
1494            return
1495        self._load_preview()
1496        self._load_settings()
1497
1498    def _load_preview(self):
1499        """Loads the brush preview as pixbuf into the brush."""
1500        assert self.name
1501        prefix = self._get_fileprefix()
1502        filename = prefix + '_prev.png'
1503        try:
1504            pixbuf = GdkPixbuf.Pixbuf.new_from_file(filename)
1505        except Exception:
1506            logger.exception("Failed to load preview pixbuf, will fall back "
1507                             "to default")
1508            pixbuf = None
1509        self._preview = pixbuf
1510        self._remember_mtimes()
1511
1512    def _load_settings(self):
1513        """Loads the brush settings/dynamics from disk."""
1514        prefix = self._get_fileprefix()
1515        filename = prefix + '.myb'
1516        with open(filename) as fp:
1517            brushinfo_str = fp.read()
1518        try:
1519            self._brushinfo.load_from_string(brushinfo_str)
1520        except Exception as e:
1521            logger.warning('Failed to load brush %r: %s', filename, e)
1522            self._brushinfo.load_defaults()
1523        self._remember_mtimes()
1524        self._settings_loaded = True
1525        if self.bm.is_in_brushlist(self):  # FIXME: get rid of this check
1526            self._brushinfo.set_string_property("parent_brush_name", None)
1527        self.persistent = True
1528
1529    def _has_changed_on_disk(self):
1530        prefix = self._get_fileprefix()
1531        if self._preview_mtime != os.path.getmtime(prefix + '_prev.png'):
1532            return True
1533        if self._settings_mtime != os.path.getmtime(prefix + '.myb'):
1534            return True
1535        return False
1536
1537    def reload_if_changed(self):
1538        if self._settings_mtime is None:
1539            return
1540        if self._preview_mtime is None:
1541            return
1542        if not self.name:
1543            return
1544        if not self._has_changed_on_disk():
1545            return False
1546        logger.info('Brush %r has changed on disk, reloading it.',
1547                    self.name)
1548        self.load()
1549        return True
1550
1551    def _ensure_settings_loaded(self):
1552        """Ensures the brush's settings are loaded, if persistent"""
1553        if self.persistent and not self._settings_loaded:
1554            logger.debug("Loading %r...", self)
1555            self.load()
1556            assert self._settings_loaded
1557
1558
1559class InvalidBrushpack (Exception):
1560    """Raised when brushpacks cannot be imported."""
1561
1562
1563## Module testing
1564
1565if __name__ == '__main__':
1566    import doctest
1567    doctest.testmod()
1568