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