1# -*- coding: utf-8 -*- 2# This file is part of MyPaint. 3# Copyright (C) 2009-2019 by the MyPaint Development Team 4# Copyright (C) 2007-2014 by Martin Renold <martinxyz@gmx.ch> 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 opening/saving.""" 12 13 14## Imports 15 16from __future__ import division, print_function 17 18import os 19import re 20from glob import glob 21import sys 22import logging 23from collections import OrderedDict 24import time 25 26from lib.gibindings import Gtk 27from lib.gibindings import Pango 28 29from lib import helpers 30from lib import fileutils 31from lib.errors import FileHandlingError 32from lib.errors import AllocationError 33import gui.compatibility as compat 34from gui.widgets import with_wait_cursor 35from lib import mypaintlib 36from lib.gettext import ngettext 37from lib.gettext import C_ 38import lib.glib 39from lib.glib import filename_to_unicode 40import lib.xml 41import lib.feedback 42from lib.pycompat import unicode, PY3 43 44logger = logging.getLogger(__name__) 45 46 47## Save format consts 48 49class _SaveFormat: 50 """Safe format consts.""" 51 ANY = 0 52 ORA = 1 53 PNG_AUTO = 2 54 PNG_SOLID = 3 55 PNG_TRANS = 4 56 PNGS_BY_LAYER = 5 57 PNGS_BY_VIEW = 6 58 JPEG = 7 59 60 61## Internal helper funcs 62 63 64def _get_case_insensitive_glob(string): 65 """Converts a glob pattern into a case-insensitive glob pattern. 66 67 >>> _get_case_insensitive_glob('*.ora') 68 '*.[oO][rR][aA]' 69 70 This utility function is a workaround for the GTK 71 FileChooser/FileFilter not having an easy way to use case 72 insensitive filters 73 74 """ 75 ext = string.split('.')[1] 76 globlist = ["[%s%s]" % (c.lower(), c.upper()) for c in ext] 77 return '*.%s' % ''.join(globlist) 78 79 80def _add_filters_to_dialog(filters, dialog): 81 """Adds Gtk.FileFilter objs for patterns to a dialog.""" 82 for name, patterns in filters: 83 f = Gtk.FileFilter() 84 f.set_name(name) 85 for p in patterns: 86 f.add_pattern(_get_case_insensitive_glob(p)) 87 dialog.add_filter(f) 88 89 90def _dialog_set_filename(dialog, s): 91 """Sets the filename and folder visible in a dialog. 92 93 According to the PyGTK documentation we should use set_filename(); 94 however, doing so removes the selected file filter. 95 96 TODO: verify whether this is still needed with GTK3+PyGI. 97 98 """ 99 path, name = os.path.split(s) 100 dialog.set_current_folder(path) 101 dialog.set_current_name(name) 102 103 104## Class definitions 105 106class _IOProgressUI: 107 """Wraps IO activity calls to show progress to the user. 108 109 Code about to do a potentially lengthy save or load operation 110 constructs one one of these temporary state manager objects, and 111 uses it to call their supplied IO callable. The _IOProgressUI 112 supplies the IO callable with a lib.feedback.Progress object which 113 deeper levels will need to call regularly to keep the UI updated. 114 Statusbar messages and error or progress dialogs may be shown via 115 the main application. 116 117 Yes, this sounds a lot like context managers and IO coroutines, 118 and maybe one day it all will be just that. 119 120 """ 121 122 # Message templating consts: 123 124 _OP_DURATION_TEMPLATES = { 125 "load": C_( 126 "Document I/O: message shown while working", 127 u"Loading {files_summary}…", 128 ), 129 "import": C_( 130 "Document I/O: message shown while working", 131 u"Importing layers from {files_summary}…", 132 ), 133 "save": C_( 134 "Document I/O: message shown while working", 135 u"Saving {files_summary}…", 136 ), 137 "export": C_( 138 "Document I/O: message shown while working", 139 u"Exporting to {files_summary}…", 140 ), 141 } 142 143 _OP_FAILED_TEMPLATES = { 144 "export": C_( 145 "Document I/O: fail message", 146 u"Failed to export to {files_summary}.", 147 ), 148 "save": C_( 149 "Document I/O: fail message", 150 u"Failed to save {files_summary}.", 151 ), 152 "import": C_( 153 "Document I/O: fail message", 154 u"Could not import layers from {files_summary}.", 155 ), 156 "load": C_( 157 "Document I/O: fail message", 158 u"Could not load {files_summary}.", 159 ), 160 } 161 162 _OP_FAIL_DIALOG_TITLES = { 163 "save": C_( 164 "Document I/O: fail dialog title", 165 u"Save failed", 166 ), 167 "export": C_( 168 "Document I/O: fail dialog title", 169 u"Export failed", 170 ), 171 "import": C_( 172 "Document I/O: fail dialog title", 173 u"Import Layers failed", 174 ), 175 "load": C_( 176 "Document I/O: fail dialog title", 177 u"Open failed", 178 ), 179 } 180 181 _OP_SUCCEEDED_TEMPLATES = { 182 "export": C_( 183 "Document I/O: success", 184 u"Exported to {files_summary} successfully.", 185 ), 186 "save": C_( 187 "Document I/O: success", 188 u"Saved {files_summary} successfully.", 189 ), 190 "import": C_( 191 "Document I/O: success", 192 u"Imported layers from {files_summary}.", 193 ), 194 "load": C_( 195 "Document I/O: success", 196 u"Loaded {files_summary}.", 197 ), 198 } 199 200 # Message templating: 201 202 @staticmethod 203 def format_files_summary(f): 204 """The suggested way of formatting 1+ filenames for display. 205 206 :param f: A list of filenames, or a single filename. 207 :returns: A files_summary value for the constructor. 208 :rtype: unicode|str 209 210 """ 211 if isinstance(f, tuple) or isinstance(f, list): 212 nfiles = len(f) 213 # TRANSLATORS: formatting for {files_summary} for multiple files. 214 # TRANSLATORS: corresponding msgid for single files: "“{basename}”" 215 return ngettext(u"{n} file", u"{n} files", nfiles).format( 216 n=nfiles, 217 ) 218 elif isinstance(f, bytes) or isinstance(f, unicode): 219 if isinstance(f, bytes): 220 f = f.decode("utf-8") 221 return C_( 222 "Document I/O: the {files_summary} for a single file", 223 u"“{basename}”", 224 ).format(basename=os.path.basename(f)) 225 else: 226 raise TypeError("Expected a string, or a sequence of strings.") 227 228 # Method defs: 229 230 def __init__(self, app, op_type, files_summary, 231 use_statusbar=True, use_dialogs=True): 232 """Construct, describing what UI messages to show. 233 234 :param app: The top-level MyPaint application object. 235 :param str op_type: What kind of operation is about to happen. 236 :param unicode files-summary: User-visible descripion of files. 237 :param bool use_statusbar: Show statusbar messages for feedback. 238 :param bool use_dialogs: Whether to use dialogs for feedback. 239 240 """ 241 self._app = app 242 self.clock_func = time.perf_counter if PY3 else time.clock 243 244 files_summary = unicode(files_summary) 245 op_type = str(op_type) 246 if op_type not in self._OP_DURATION_TEMPLATES: 247 raise ValueError("Unknown operation type %r" % (op_type,)) 248 249 msg = self._OP_DURATION_TEMPLATES[op_type].format( 250 files_summary = files_summary, 251 ) 252 self._duration_msg = msg 253 254 msg = self._OP_SUCCEEDED_TEMPLATES[op_type].format( 255 files_summary = files_summary, 256 ) 257 self._success_msg = msg 258 259 msg = self._OP_FAILED_TEMPLATES[op_type].format( 260 files_summary = files_summary, 261 ) 262 self._fail_msg = msg 263 264 msg = self._OP_FAIL_DIALOG_TITLES[op_type] 265 self._fail_dialog_title = msg 266 267 self._is_write = (op_type in ["save", "export"]) 268 269 cid = self._app.statusbar.get_context_id("filehandling-message") 270 self._statusbar_context_id = cid 271 272 self._use_statusbar = bool(use_statusbar) 273 self._use_dialogs = bool(use_dialogs) 274 275 #: True only if the IO function run by call() succeeded. 276 self.success = False 277 278 self._progress_dialog = None 279 self._progress_bar = None 280 self._start_time = None 281 self._last_pulse = None 282 283 @with_wait_cursor 284 def call(self, func, *args, **kwargs): 285 """Call a save or load callable and watch its progress. 286 287 :param callable func: The IO function to be called. 288 :param \*args: Passed to func. 289 :param \*\*kwargs: Passed to func. 290 :returns: The return value of func. 291 292 Messages about the operation in progress may be shown to the 293 user according to the object's op_type and files_summary. The 294 supplied callable is called with a *args and **kwargs, plus a 295 "progress" keyword argument that when updated will keep the UI 296 managed by this object updated. 297 298 If the callable returned, self.success is set to True. If it 299 raised an exception, it will remain False. 300 301 See also: lib.feedback.Progress. 302 303 """ 304 statusbar = self._app.statusbar 305 progress = lib.feedback.Progress() 306 progress.changed += self._progress_changed_cb 307 kwargs = kwargs.copy() 308 kwargs["progress"] = progress 309 310 cid = self._statusbar_context_id 311 if self._use_statusbar: 312 statusbar.remove_all(cid) 313 statusbar.push(cid, self._duration_msg) 314 315 self._start_time = self.clock_func() 316 self._last_pulse = None 317 result = None 318 try: 319 result = func(*args, **kwargs) 320 except (FileHandlingError, AllocationError, MemoryError) as e: 321 # Catch predictable exceptions here, and don't re-raise 322 # them. Dialogs may be shown, but they will use 323 # understandable language. 324 logger.exception( 325 u"IO failed (user-facing explanations: %s / %s)", 326 self._fail_msg, 327 unicode(e), 328 ) 329 if self._use_statusbar: 330 statusbar.remove_all(cid) 331 self._app.show_transient_message(self._fail_msg) 332 if self._use_dialogs: 333 self._app.message_dialog( 334 title=self._fail_dialog_title, 335 text=self._fail_msg, 336 secondary_text=unicode(e), 337 message_type=Gtk.MessageType.ERROR, 338 ) 339 self.success = False 340 else: 341 if result is False: 342 logger.info("IO operation was cancelled by the user") 343 else: 344 logger.info("IO succeeded: %s", self._success_msg) 345 if self._use_statusbar: 346 statusbar.remove_all(cid) 347 if result is not False: 348 self._app.show_transient_message(self._success_msg) 349 self.success = result is not False 350 finally: 351 if self._progress_bar is not None: 352 self._progress_dialog.destroy() 353 self._progress_dialog = None 354 self._progress_bar = None 355 return result 356 357 def _progress_changed_cb(self, progress): 358 if self._progress_bar is None: 359 now = self.clock_func() 360 if (now - self._start_time) > 0.25: 361 dialog = Gtk.Dialog( 362 title=self._duration_msg, 363 transient_for=self._app.drawWindow, 364 modal=True, 365 destroy_with_parent=True, 366 ) 367 dialog.set_position(Gtk.WindowPosition.CENTER_ON_PARENT) 368 dialog.set_decorated(False) 369 style = dialog.get_style_context() 370 style.add_class(Gtk.STYLE_CLASS_OSD) 371 372 label = Gtk.Label() 373 label.set_text(self._duration_msg) 374 label.set_ellipsize(Pango.EllipsizeMode.MIDDLE) 375 376 progress_bar = Gtk.ProgressBar() 377 progress_bar.set_size_request(400, -1) 378 379 dialog.vbox.set_border_width(16) 380 dialog.vbox.set_spacing(8) 381 dialog.vbox.pack_start(label, True, True, 0) 382 dialog.vbox.pack_start(progress_bar, True, True, 0) 383 384 progress_bar.show() 385 dialog.show_all() 386 self._progress_dialog = dialog 387 self._progress_bar = progress_bar 388 self._last_pulse = now 389 390 self._update_progress_bar(progress) 391 self._process_gtk_events() 392 393 def _update_progress_bar(self, progress): 394 if not self._progress_bar: 395 return 396 fraction = progress.fraction 397 if fraction is None: 398 now = self.clock_func() 399 if (now - self._last_pulse) > 0.1: 400 self._progress_bar.pulse() 401 self._last_pulse = now 402 else: 403 self._progress_bar.set_fraction(fraction) 404 405 def _process_gtk_events(self): 406 while Gtk.events_pending(): 407 Gtk.main_iteration() 408 409 410class FileHandler (object): 411 """File handling object, part of the central app object. 412 413 A single app-wide instance of this object is accessible from the 414 central gui.application.Application instance as as app.filehandler. 415 Several GTK action callbacks for opening and saving files reside 416 here, and the object's public methods may be called from other parts 417 of the application. 418 419 NOTE: filehandling and drawwindow are very tightly coupled. 420 421 """ 422 423 def __init__(self, app): 424 self.app = app 425 self.save_dialog = None 426 427 # File filters definitions, for dialogs 428 # (name, patterns) 429 self.file_filters = [( 430 C_( 431 "save/load dialogs: filter patterns", 432 u"All Recognized Formats", 433 ), ["*.ora", "*.png", "*.jpg", "*.jpeg"], 434 ), ( 435 C_( 436 "save/load dialogs: filter patterns", 437 u"OpenRaster (*.ora)", 438 ), ["*.ora"], 439 ), ( 440 C_( 441 "save/load dialogs: filter patterns", 442 u"PNG (*.png)", 443 ), ["*.png"], 444 ), ( 445 C_( 446 "save/load dialogs: filter patterns", 447 u"JPEG (*.jpg; *.jpeg)", 448 ), ["*.jpg", "*.jpeg"], 449 )] 450 451 # Recent filter, for the menu. 452 # Better to use a regex with re.IGNORECASE than 453 # .upper()==.upper() hacks since internally, filenames are 454 # Unicode and capitalization rules like Turkish's dotless "i" 455 # exist. One day we want all the formats GdkPixbuf can load to 456 # be supported in the dialog. 457 458 file_regex_exts = set() 459 for name, patts in self.file_filters: 460 for p in patts: 461 e = p.replace("*.", "", 1) 462 file_regex_exts.add(re.escape(e)) 463 file_re = r'[.](?:' + ('|'.join(file_regex_exts)) + r')$' 464 logger.debug("Using regex /%s/i for filtering recent files", file_re) 465 self._file_extension_regex = re.compile(file_re, re.IGNORECASE) 466 rf = Gtk.RecentFilter() 467 rf.add_pattern('') 468 # The blank-string pattern is eeded so the custom func will 469 # get URIs at all, despite the needed flags below. 470 rf.add_custom( 471 func = self._recentfilter_func, 472 needed = ( 473 Gtk.RecentFilterFlags.APPLICATION | 474 Gtk.RecentFilterFlags.URI 475 ) 476 ) 477 ra = app.find_action("OpenRecent") 478 ra.add_filter(rf) 479 480 ag = app.builder.get_object('FileActions') 481 for action in ag.list_actions(): 482 self.app.kbm.takeover_action(action) 483 484 self._filename = None 485 self.current_file_observers = [] 486 self.file_opened_observers = [] 487 self.active_scrap_filename = None 488 self.lastsavefailed = False 489 self._update_recent_items() 490 491 # { FORMAT: (name, extension, options) } 492 self.saveformats = OrderedDict([ 493 (_SaveFormat.ANY, (C_( 494 "save dialogs: save formats and options", 495 u"By extension (prefer default format)", 496 ), None, {})), 497 (_SaveFormat.ORA, (C_( 498 "save dialogs: save formats and options", 499 u"OpenRaster (*.ora)", 500 ), '.ora', {})), 501 (_SaveFormat.PNG_AUTO, (C_( 502 "save dialogs: save formats and options", 503 u"PNG, respecting “Show Background” (*.png)" 504 ), '.png', {})), 505 (_SaveFormat.PNG_SOLID, (C_( 506 "save dialogs: save formats and options", 507 u"PNG, solid RGB (*.png)", 508 ), '.png', {'alpha': False})), 509 (_SaveFormat.PNG_TRANS, (C_( 510 "save dialogs: save formats and options", 511 u"PNG, transparent RGBA (*.png)", 512 ), '.png', {'alpha': True})), 513 (_SaveFormat.PNGS_BY_LAYER, (C_( 514 "save dialogs: save formats and options", 515 u"Multiple PNGs, by layer (*.NUM.png)", 516 ), '.png', {'multifile': 'layers'})), 517 (_SaveFormat.PNGS_BY_VIEW, (C_( 518 "save dialogs: save formats and options", 519 u"Multiple PNGs, by view (*.NAME.png)", 520 ), '.png', {'multifile': 'views'})), 521 (_SaveFormat.JPEG, (C_( 522 "save dialogs: save formats and options", 523 u"JPEG 90% quality (*.jpg; *.jpeg)", 524 ), '.jpg', {'quality': 90})), 525 ]) 526 self.ext2saveformat = { 527 ".ora": (_SaveFormat.ORA, "image/openraster"), 528 ".png": (_SaveFormat.PNG_AUTO, "image/png"), 529 ".jpeg": (_SaveFormat.JPEG, "image/jpeg"), 530 ".jpg": (_SaveFormat.JPEG, "image/jpeg"), 531 } 532 self.config2saveformat = { 533 'openraster': _SaveFormat.ORA, 534 'jpeg-90%': _SaveFormat.JPEG, 535 'png-solid': _SaveFormat.PNG_SOLID, 536 } 537 538 def _update_recent_items(self): 539 """Updates self._recent_items from the GTK RecentManager. 540 541 This list is consumed in open_last_cb. 542 543 """ 544 # Note: i.exists() does not work on Windows if the pathname 545 # contains utf-8 characters. Since GIMP also saves its URIs 546 # with utf-8 characters into this list, I assume this is a 547 # gtk bug. So we use our own test instead of i.exists(). 548 549 recent_items = [] 550 rm = Gtk.RecentManager.get_default() 551 for i in rm.get_items(): 552 if not i: 553 continue 554 apps = i.get_applications() 555 if not (apps and "mypaint" in apps): 556 continue 557 if self._uri_is_loadable(i.get_uri()): 558 recent_items.append(i) 559 # This test should be kept in sync with _recentfilter_func. 560 recent_items.reverse() 561 self._recent_items = recent_items 562 563 def get_filename(self): 564 return self._filename 565 566 def set_filename(self, value): 567 self._filename = value 568 for f in self.current_file_observers: 569 f(self.filename) 570 571 if self.filename: 572 if self.filename.startswith(self.get_scrap_prefix()): 573 self.active_scrap_filename = self.filename 574 575 filename = property(get_filename, set_filename) 576 577 def init_save_dialog(self, export): 578 if export: 579 save_dialog_name = C_( 580 "Dialogs (window title): File→Export…", 581 u"Export" 582 ) 583 else: 584 save_dialog_name = C_( 585 "Dialogs (window title): File→Save As…", 586 u"Save As" 587 ) 588 dialog = Gtk.FileChooserDialog( 589 save_dialog_name, 590 self.app.drawWindow, 591 Gtk.FileChooserAction.SAVE, 592 ( 593 Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, 594 Gtk.STOCK_SAVE, Gtk.ResponseType.OK, 595 ), 596 ) 597 dialog.set_default_response(Gtk.ResponseType.OK) 598 dialog.set_do_overwrite_confirmation(True) 599 _add_filters_to_dialog(self.file_filters, dialog) 600 601 # Add widget for selecting save format 602 box = Gtk.HBox() 603 box.set_spacing(12) 604 label = Gtk.Label(label=C_( 605 "save dialogs: formats and options: (label)", 606 u"Format to save as:", 607 )) 608 label.set_alignment(0.0, 0.5) 609 combo = Gtk.ComboBoxText() 610 for (name, ext, opt) in self.saveformats.values(): 611 combo.append_text(name) 612 combo.set_active(0) 613 combo.connect('changed', self.selected_save_format_changed_cb) 614 self.saveformat_combo = combo 615 616 box.pack_start(label, True, True, 0) 617 box.pack_start(combo, False, True, 0) 618 dialog.set_extra_widget(box) 619 dialog.show_all() 620 return dialog 621 622 def selected_save_format_changed_cb(self, widget): 623 """When the user changes the selected format to save as in the dialog, 624 change the extension of the filename (if existing) immediately.""" 625 dialog = self.save_dialog 626 filename = dialog.get_filename() 627 if filename: 628 filename = filename_to_unicode(filename) 629 filename, ext = os.path.splitext(filename) 630 if ext: 631 saveformat = self.saveformat_combo.get_active() 632 ext = self.saveformats[saveformat][1] 633 if ext is not None: 634 _dialog_set_filename(dialog, filename + ext) 635 636 def confirm_destructive_action(self, title=None, confirm=None, 637 offer_save=True): 638 """Asks the user to confirm an action that might lose work. 639 640 :param unicode title: Short question to ask the user. 641 :param unicode confirm: Imperative verb for the "do it" button. 642 :param bool offer_save: Set False to turn off the save checkbox. 643 :rtype: bool 644 :returns: True if the user allows the destructive action 645 646 Phrase the title question tersely. 647 In English/source, use title case for it, and with a question mark. 648 Good examples are “Really Quit?”, 649 or “Delete Everything?”. 650 The title should always tell the user 651 what destructive action is about to take place. 652 If it is not specified, a default title is used. 653 654 Use a single, specific, imperative verb for the confirm string. 655 It should reflect the title question. 656 This is used for the primary confirmation button, if specified. 657 See the GNOME HIG for further guidelines on what to use here. 658 659 This method doesn't bother asking 660 if there's less than a handful of seconds of unsaved work. 661 By default, that's 1 second. 662 The build-time and runtime debugging flags 663 make this period longer 664 to allow more convenient development and testing. 665 666 Ref: https://developer.gnome.org/hig/stable/dialogs.html.en 667 668 """ 669 if title is None: 670 title = C_( 671 "Destructive action confirm dialog: " 672 "fallback title (normally overridden)", 673 "Really Continue?" 674 ) 675 676 # Get an accurate assessment of how much change is unsaved. 677 self.doc.model.sync_pending_changes() 678 t = self.doc.model.unsaved_painting_time 679 680 # This used to be 30, but see https://gna.org/bugs/?17955 681 # Then 8 by default, but Twitter users hate that too. 682 t_bother = 1 683 if mypaintlib.heavy_debug: 684 t_bother += 7 685 if os.environ.get("MYPAINT_DEBUG", False): 686 t_bother += 7 687 logger.debug("Destructive action don't-bother period is %ds", t_bother) 688 if t < t_bother: 689 return True 690 691 # Custom response codes. 692 # The default ones are all negative ints. 693 continue_response_code = 1 694 695 # Dialog setup. 696 d = Gtk.MessageDialog( 697 title=title, 698 transient_for=self.app.drawWindow, 699 message_type=Gtk.MessageType.QUESTION, 700 modal=True 701 ) 702 703 # Translated strings for things 704 cancel_btn_text = C_( 705 "Destructive action confirm dialog: cancel button", 706 u"_Cancel", 707 ) 708 save_to_scraps_first_text = C_( 709 "Destructive action confirm dialog: save checkbox", 710 u"_Save to Scraps first", 711 ) 712 if not confirm: 713 continue_btn_text = C_( 714 "Destructive action confirm dialog: " 715 "fallback continue button (normally overridden)", 716 u"Co_ntinue", 717 ) 718 else: 719 continue_btn_text = confirm 720 721 # Button setup. Cancel first, continue at end. 722 buttons = [ 723 (cancel_btn_text, Gtk.ResponseType.CANCEL, False), 724 (continue_btn_text, continue_response_code, True), 725 ] 726 for txt, code, destructive in buttons: 727 button = d.add_button(txt, code) 728 styles = button.get_style_context() 729 if destructive: 730 styles.add_class(Gtk.STYLE_CLASS_DESTRUCTIVE_ACTION) 731 732 # Explanatory message. 733 if self.filename: 734 file_basename = os.path.basename(self.filename) 735 else: 736 file_basename = None 737 warning_msg_tmpl = C_( 738 "Destructive action confirm dialog: warning message", 739 u"You risk losing {abbreviated_time} of unsaved painting." 740 ) 741 markup_tmpl = warning_msg_tmpl 742 d.set_markup(markup_tmpl.format( 743 abbreviated_time = lib.xml.escape(helpers.fmt_time_period_abbr(t)), 744 current_file_name = lib.xml.escape(file_basename), 745 )) 746 747 # Checkbox for saving 748 if offer_save: 749 save1st_text = save_to_scraps_first_text 750 save1st_cb = Gtk.CheckButton.new_with_mnemonic(save1st_text) 751 save1st_cb.set_hexpand(False) 752 save1st_cb.set_halign(Gtk.Align.END) 753 save1st_cb.set_vexpand(False) 754 save1st_cb.set_margin_top(12) 755 save1st_cb.set_margin_bottom(12) 756 save1st_cb.set_margin_start(12) 757 save1st_cb.set_margin_end(12) 758 save1st_cb.set_can_focus(False) # set back again in show handler 759 d.connect( 760 "show", 761 self._destructive_action_dialog_show_cb, 762 save1st_cb, 763 ) 764 save1st_cb.connect( 765 "toggled", 766 self._destructive_action_dialog_save1st_toggled_cb, 767 d, 768 ) 769 vbox = d.get_content_area() 770 vbox.set_spacing(0) 771 vbox.set_margin_top(12) 772 vbox.pack_start(save1st_cb, False, True, 0) 773 774 # Get a response and handle it. 775 d.set_default_response(Gtk.ResponseType.CANCEL) 776 response_code = d.run() 777 d.destroy() 778 if response_code == continue_response_code: 779 logger.debug("Destructive action confirmed") 780 if offer_save and save1st_cb.get_active(): 781 logger.info("Saving current canvas as a new scrap") 782 self.save_scrap_cb(None) 783 return True 784 else: 785 logger.debug("Destructive action cancelled") 786 return False 787 788 def _destructive_action_dialog_show_cb(self, dialog, checkbox): 789 checkbox.show_all() 790 checkbox.set_can_focus(True) 791 792 def _destructive_action_dialog_save1st_toggled_cb(self, checkbox, dialog): 793 # Choosing to save locks you into a particular course of action. 794 # Hopefully this isn't too strange. 795 # Escape will still work. 796 cancel_allowed = not checkbox.get_active() 797 cancel_btn = dialog.get_widget_for_response(Gtk.ResponseType.CANCEL) 798 cancel_btn.set_sensitive(cancel_allowed) 799 800 def new_cb(self, action): 801 ok_to_start_new_doc = self.confirm_destructive_action( 802 title = C_( 803 u'File→New: confirm dialog: title question', 804 u"New Canvas?", 805 ), 806 confirm = C_( 807 u'File→New: confirm dialog: continue button', 808 u"_New Canvas", 809 ), 810 ) 811 if not ok_to_start_new_doc: 812 return 813 self.app.reset_compat_mode() 814 self.doc.reset_background() 815 self.doc.model.clear() 816 self.filename = None 817 self._update_recent_items() 818 self.app.doc.reset_view(True, True, True) 819 820 @staticmethod 821 def gtk_main_tick(*args, **kwargs): 822 while Gtk.events_pending(): 823 Gtk.main_iteration() 824 825 def open_file(self, filename, **kwargs): 826 """Load a file, replacing the current working document.""" 827 if not self._call_doc_load_method( 828 self.doc.model.load, filename, False, **kwargs): 829 # Without knowledge of _when_ the process failed, clear 830 # the document to make sure we're not in an inconsistent state. 831 # TODO: Improve the control flow to permit a less draconian 832 # approach, for exceptions occurring prior to any doc-changes. 833 self.filename = None 834 self.app.reset_compat_mode() 835 self.doc.model.clear() 836 return 837 838 self.filename = os.path.abspath(filename) 839 for func in self.file_opened_observers: 840 func(self.filename) 841 logger.info('Loaded from %r', self.filename) 842 self.app.doc.reset_view(True, True, True) 843 # try to restore the last used brush and color 844 layers = self.doc.model.layer_stack 845 search_layers = [] 846 if layers.current is not None: 847 search_layers.append(layers.current) 848 search_layers.extend(layers.deepiter()) 849 for layer in search_layers: 850 si = layer.get_last_stroke_info() 851 if si: 852 self.app.restore_brush_from_stroke_info(si) 853 break 854 855 def import_layers(self, filenames): 856 """Load a file, replacing the current working document.""" 857 858 if not self._call_doc_load_method(self.doc.model.import_layers, 859 filenames, True): 860 return 861 logger.info('Imported layers from %r', filenames) 862 863 def _call_doc_load_method( 864 self, method, arg, is_import, compat_handler=None): 865 """Internal: common GUI aspects of loading or importing files. 866 867 Calls a document model loader method (on lib.document.Document) 868 with the given argument. Catches common loading exceptions and 869 shows appropriate error messages. 870 871 """ 872 if not compat_handler: 873 compat_handler = compat.ora_compat_handler(self.app) 874 prefs = self.app.preferences 875 display_colorspace_setting = prefs["display.colorspace"] 876 877 op_type = is_import and "import" or "load" 878 879 files_summary = _IOProgressUI.format_files_summary(arg) 880 ioui = _IOProgressUI(self.app, op_type, files_summary) 881 result = ioui.call( 882 method, arg, 883 convert_to_srgb=(display_colorspace_setting == "srgb"), 884 compat_handler=compat_handler, 885 incompatible_ora_cb=compat.incompatible_ora_cb(self.app) 886 ) 887 return (result is not False) and ioui.success 888 889 def open_scratchpad(self, filename): 890 no_ui_progress = lib.feedback.Progress() 891 no_ui_progress.changed += self.gtk_main_tick 892 try: 893 self.app.scratchpad_doc.model.load( 894 filename, 895 progress=no_ui_progress, 896 ) 897 self.app.scratchpad_filename = os.path.abspath(filename) 898 self.app.preferences["scratchpad.last_opened_scratchpad"] \ 899 = self.app.scratchpad_filename 900 except (FileHandlingError, AllocationError, MemoryError) as e: 901 self.app.message_dialog( 902 unicode(e), 903 message_type=Gtk.MessageType.ERROR 904 ) 905 else: 906 self.app.scratchpad_filename = os.path.abspath(filename) 907 self.app.preferences["scratchpad.last_opened_scratchpad"] \ 908 = self.app.scratchpad_filename 909 logger.info('Loaded scratchpad from %r', 910 self.app.scratchpad_filename) 911 self.app.scratchpad_doc.reset_view(True, True, True) 912 913 def save_file(self, filename, export=False, **options): 914 """Saves the main document to one or more files (app/toplevel) 915 916 :param filename: The base filename to save 917 :param bool export: True if exporting 918 :param **options: Pass-through options 919 920 This method invokes `_save_doc_to_file()` with the main working 921 doc, but also attempts to save thumbnails and perform recent 922 files list management, when appropriate. 923 924 See `_save_doc_to_file()` 925 """ 926 thumbnail_pixbuf = self._save_doc_to_file( 927 filename, 928 self.doc, 929 export=export, 930 use_statusbar=True, 931 **options 932 ) 933 if "multifile" in options: # thumbs & recents are inappropriate 934 return 935 if not os.path.isfile(filename): # failed to save 936 return 937 if not export: 938 self.filename = os.path.abspath(filename) 939 basename, ext = os.path.splitext(self.filename) 940 recent_mgr = Gtk.RecentManager.get_default() 941 uri = lib.glib.filename_to_uri(self.filename) 942 recent_data = Gtk.RecentData() 943 recent_data.app_name = "mypaint" 944 app_exec = sys.argv_unicode[0] 945 assert isinstance(app_exec, unicode) 946 recent_data.app_exec = app_exec 947 mime_default = "application/octet-stream" 948 fmt, mime_type = self.ext2saveformat.get(ext, (None, mime_default)) 949 recent_data.mime_type = mime_type 950 recent_mgr.add_full(uri, recent_data) 951 if not thumbnail_pixbuf: 952 options["render_background"] = not options.get("alpha", False) 953 thumbnail_pixbuf = self.doc.model.render_thumbnail(**options) 954 helpers.freedesktop_thumbnail(filename, thumbnail_pixbuf) 955 956 @with_wait_cursor 957 def save_scratchpad(self, filename, export=False, **options): 958 save_needed = ( 959 self.app.scratchpad_doc.model.unsaved_painting_time 960 or export 961 or not os.path.exists(filename) 962 ) 963 if save_needed: 964 self._save_doc_to_file( 965 filename, 966 self.app.scratchpad_doc, 967 export=export, 968 use_statusbar=False, 969 **options 970 ) 971 if not export: 972 self.app.scratchpad_filename = os.path.abspath(filename) 973 self.app.preferences["scratchpad.last_opened_scratchpad"] \ 974 = self.app.scratchpad_filename 975 976 def _save_doc_to_file(self, filename, doc, export=False, 977 use_statusbar=True, 978 **options): 979 """Saves a document to one or more files 980 981 :param filename: The base filename to save 982 :param Document doc: Controller for the document to save 983 :param bool export: True if exporting 984 :param **options: Pass-through options 985 986 This method handles logging, statusbar messages, 987 and alerting the user to when the save failed. 988 989 See also: lib.document.Document.save(), _IOProgressUI. 990 """ 991 thumbnail_pixbuf = None 992 prefs = self.app.preferences 993 display_colorspace_setting = prefs["display.colorspace"] 994 options['save_srgb_chunks'] = (display_colorspace_setting == "srgb") 995 996 files_summary = _IOProgressUI.format_files_summary(filename) 997 op_type = export and "export" or "save" 998 ioui = _IOProgressUI(self.app, op_type, files_summary, 999 use_statusbar=use_statusbar) 1000 1001 thumbnail_pixbuf = ioui.call(doc.model.save, filename, **options) 1002 self.lastsavefailed = not ioui.success 1003 return thumbnail_pixbuf 1004 1005 def update_preview_cb(self, file_chooser, preview): 1006 filename = file_chooser.get_preview_filename() 1007 if filename: 1008 filename = filename_to_unicode(filename) 1009 pixbuf = helpers.freedesktop_thumbnail(filename) 1010 if pixbuf: 1011 # if pixbuf is smaller than 256px in width, copy it onto 1012 # a transparent 256x256 pixbuf 1013 pixbuf = helpers.pixbuf_thumbnail(pixbuf, 256, 256, True) 1014 preview.set_from_pixbuf(pixbuf) 1015 file_chooser.set_preview_widget_active(True) 1016 else: 1017 # TODO: display "no preview available" image? 1018 file_chooser.set_preview_widget_active(False) 1019 1020 def open_cb(self, action): 1021 ok_to_open = self.app.filehandler.confirm_destructive_action( 1022 title = C_( 1023 u'File→Open: confirm dialog: title question', 1024 u"Open File?", 1025 ), 1026 confirm = C_( 1027 u'File→Open: confirm dialog: continue button', 1028 u"_Open…", 1029 ), 1030 ) 1031 if not ok_to_open: 1032 return 1033 dialog = Gtk.FileChooserDialog( 1034 title=C_( 1035 u'File→Open: file chooser dialog: title', 1036 u"Open File", 1037 ), 1038 transient_for=self.app.drawWindow, 1039 action=Gtk.FileChooserAction.OPEN, 1040 ) 1041 dialog.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) 1042 dialog.add_button(Gtk.STOCK_OPEN, Gtk.ResponseType.OK) 1043 dialog.set_default_response(Gtk.ResponseType.OK) 1044 1045 # Compatibility override options for .ora files 1046 selector = compat.CompatSelector(self.app) 1047 dialog.connect('selection-changed', selector.file_selection_changed_cb) 1048 dialog.set_extra_widget(selector.widget) 1049 1050 preview = Gtk.Image() 1051 dialog.set_preview_widget(preview) 1052 dialog.connect("update-preview", self.update_preview_cb, preview) 1053 1054 _add_filters_to_dialog(self.file_filters, dialog) 1055 1056 if self.filename: 1057 dialog.set_filename(self.filename) 1058 else: 1059 # choose the most recent save folder 1060 self._update_recent_items() 1061 for item in reversed(self._recent_items): 1062 uri = item.get_uri() 1063 fn, _h = lib.glib.filename_from_uri(uri) 1064 dn = os.path.dirname(fn) 1065 if os.path.isdir(dn): 1066 dialog.set_current_folder(dn) 1067 break 1068 try: 1069 if dialog.run() == Gtk.ResponseType.OK: 1070 dialog.hide() 1071 filename = dialog.get_filename() 1072 filename = filename_to_unicode(filename) 1073 self.open_file( 1074 filename, 1075 compat_handler=selector.compat_function 1076 ) 1077 finally: 1078 dialog.destroy() 1079 1080 def open_scratchpad_dialog(self): 1081 dialog = Gtk.FileChooserDialog( 1082 C_( 1083 "load dialogs: title", 1084 u"Open Scratchpad…", 1085 ), 1086 self.app.drawWindow, 1087 Gtk.FileChooserAction.OPEN, 1088 (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, 1089 Gtk.STOCK_OPEN, Gtk.ResponseType.OK), 1090 ) 1091 dialog.set_default_response(Gtk.ResponseType.OK) 1092 1093 preview = Gtk.Image() 1094 dialog.set_preview_widget(preview) 1095 dialog.connect("update-preview", self.update_preview_cb, preview) 1096 1097 _add_filters_to_dialog(self.file_filters, dialog) 1098 1099 if self.app.scratchpad_filename: 1100 dialog.set_filename(self.app.scratchpad_filename) 1101 else: 1102 # choose the most recent save folder 1103 self._update_recent_items() 1104 for item in reversed(self._recent_items): 1105 uri = item.get_uri() 1106 fn, _h = lib.glib.filename_from_uri(uri) 1107 dn = os.path.dirname(fn) 1108 if os.path.isdir(dn): 1109 dialog.set_current_folder(dn) 1110 break 1111 try: 1112 if dialog.run() == Gtk.ResponseType.OK: 1113 dialog.hide() 1114 filename = dialog.get_filename() 1115 filename = filename_to_unicode(filename) 1116 self.app.scratchpad_filename = filename 1117 self.open_scratchpad(filename) 1118 finally: 1119 dialog.destroy() 1120 1121 def import_layers_cb(self, action): 1122 """Action callback: import layers from multiple files.""" 1123 dialog = Gtk.FileChooserDialog( 1124 title = C_( 1125 u'Layers→Import Layers: files-chooser dialog: title', 1126 u"Import Layers", 1127 ), 1128 parent = self.app.drawWindow, 1129 action = Gtk.FileChooserAction.OPEN, 1130 ) 1131 dialog.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) 1132 dialog.add_button(Gtk.STOCK_OPEN, Gtk.ResponseType.OK) 1133 dialog.set_default_response(Gtk.ResponseType.OK) 1134 1135 dialog.set_select_multiple(True) 1136 1137 # TODO: decide how well the preview plays with multiple-select. 1138 preview = Gtk.Image() 1139 dialog.set_preview_widget(preview) 1140 dialog.connect("update-preview", self.update_preview_cb, preview) 1141 1142 _add_filters_to_dialog(self.file_filters, dialog) 1143 1144 # Choose the most recent save folder. 1145 self._update_recent_items() 1146 for item in reversed(self._recent_items): 1147 uri = item.get_uri() 1148 fn, _h = lib.glib.filename_from_uri(uri) 1149 dn = os.path.dirname(fn) 1150 if os.path.isdir(dn): 1151 dialog.set_current_folder(dn) 1152 break 1153 1154 filenames = [] 1155 try: 1156 if dialog.run() == Gtk.ResponseType.OK: 1157 dialog.hide() 1158 filenames = dialog.get_filenames() 1159 finally: 1160 dialog.destroy() 1161 1162 if filenames: 1163 filenames = [filename_to_unicode(f) for f in filenames] 1164 self.import_layers(filenames) 1165 1166 def save_cb(self, action): 1167 if not self.filename: 1168 self.save_as_cb(action) 1169 else: 1170 self.save_file(self.filename) 1171 1172 def save_as_cb(self, action): 1173 if self.filename: 1174 current_filename = self.filename 1175 else: 1176 current_filename = '' 1177 # choose the most recent save folder 1178 self._update_recent_items() 1179 for item in reversed(self._recent_items): 1180 uri = item.get_uri() 1181 fn, _h = lib.glib.filename_from_uri(uri) 1182 dn = os.path.dirname(fn) 1183 if os.path.isdir(dn): 1184 break 1185 1186 self.save_as_dialog( 1187 self.save_file, 1188 suggested_filename=current_filename, 1189 export = (action.get_name() == 'Export'), 1190 ) 1191 1192 def save_scratchpad_as_dialog(self, export=False): 1193 if self.app.scratchpad_filename: 1194 current_filename = self.app.scratchpad_filename 1195 else: 1196 current_filename = '' 1197 1198 self.save_as_dialog( 1199 self.save_scratchpad, 1200 suggested_filename=current_filename, 1201 export=export, 1202 ) 1203 1204 def save_as_dialog(self, save_method_reference, suggested_filename=None, 1205 start_in_folder=None, export=False, 1206 **options): 1207 if not self.save_dialog: 1208 self.save_dialog = self.init_save_dialog(export) 1209 dialog = self.save_dialog 1210 # Set the filename in the dialog 1211 if suggested_filename: 1212 _dialog_set_filename(dialog, suggested_filename) 1213 else: 1214 _dialog_set_filename(dialog, '') 1215 # Recent directory? 1216 if start_in_folder: 1217 dialog.set_current_folder(start_in_folder) 1218 1219 try: 1220 # Loop until we have filename with an extension 1221 while dialog.run() == Gtk.ResponseType.OK: 1222 filename = dialog.get_filename() 1223 if filename is None: 1224 continue 1225 filename = filename_to_unicode(filename) 1226 name, ext = os.path.splitext(filename) 1227 saveformat = self.saveformat_combo.get_active() 1228 1229 # If no explicitly selected format, use the extension to 1230 # figure it out 1231 if saveformat == _SaveFormat.ANY: 1232 cfg = self.app.preferences['saving.default_format'] 1233 default_saveformat = self.config2saveformat[cfg] 1234 if ext: 1235 try: 1236 saveformat, mime = self.ext2saveformat[ext] 1237 except KeyError: 1238 saveformat = default_saveformat 1239 else: 1240 saveformat = default_saveformat 1241 1242 # if saveformat isn't a key, it must be SAVE_FORMAT_PNGAUTO. 1243 desc, ext_format, options = self.saveformats.get( 1244 saveformat, 1245 ("", ext, {'alpha': None}), 1246 ) 1247 1248 if ext: 1249 if ext_format != ext: 1250 # Minor ugliness: if the user types '.png' but 1251 # leaves the default .ora filter selected, we 1252 # use the default options instead of those 1253 # above. However, they are the same at the moment. 1254 options = {} 1255 assert(filename) 1256 dialog.hide() 1257 if export: 1258 # Do not change working file 1259 save_method_reference(filename, True, **options) 1260 else: 1261 save_method_reference(filename, **options) 1262 break 1263 1264 filename = name + ext_format 1265 1266 # trigger overwrite confirmation for the modified filename 1267 _dialog_set_filename(dialog, filename) 1268 dialog.response(Gtk.ResponseType.OK) 1269 1270 finally: 1271 dialog.hide() 1272 dialog.destroy() # avoid GTK crash: https://gna.org/bugs/?17902 1273 self.save_dialog = None 1274 1275 def save_scrap_cb(self, action): 1276 filename = self.filename 1277 prefix = self.get_scrap_prefix() 1278 self.app.filename = self.save_autoincrement_file( 1279 filename, 1280 prefix, 1281 main_doc=True, 1282 ) 1283 1284 def save_scratchpad_cb(self, action): 1285 filename = self.app.scratchpad_filename 1286 prefix = self.get_scratchpad_prefix() 1287 self.app.scratchpad_filename = self.save_autoincrement_file( 1288 filename, 1289 prefix, 1290 main_doc=False, 1291 ) 1292 1293 def save_autoincrement_file(self, filename, prefix, main_doc=True): 1294 # If necessary, create the folder(s) the scraps are stored under 1295 prefix_dir = os.path.dirname(prefix) 1296 if not os.path.exists(prefix_dir): 1297 os.makedirs(prefix_dir) 1298 1299 number = None 1300 if filename: 1301 junk, file_fragment = os.path.split(filename) 1302 if file_fragment.startswith("_md5"): 1303 # store direct, don't attempt to increment 1304 if main_doc: 1305 self.save_file(filename) 1306 else: 1307 self.save_scratchpad(filename) 1308 return filename 1309 1310 found_nums = re.findall(re.escape(prefix) + '([0-9]+)', filename) 1311 if found_nums: 1312 number = found_nums[0] 1313 1314 if number: 1315 # reuse the number, find the next character 1316 char = 'a' 1317 for filename in glob(prefix + number + '_*'): 1318 c = filename[len(prefix + number + '_')] 1319 if c >= 'a' and c <= 'z' and c >= char: 1320 char = chr(ord(c) + 1) 1321 if char > 'z': 1322 # out of characters, increase the number 1323 filename = None 1324 return self.save_autoincrement_file(filename, prefix, main_doc) 1325 filename = '%s%s_%c' % (prefix, number, char) 1326 else: 1327 # we don't have a scrap filename yet, find the next number 1328 maximum = 0 1329 for filename in glob(prefix + '[0-9][0-9][0-9]*'): 1330 filename = filename[len(prefix):] 1331 res = re.findall(r'[0-9]*', filename) 1332 if not res: 1333 continue 1334 number = int(res[0]) 1335 if number > maximum: 1336 maximum = number 1337 filename = '%s%03d_a' % (prefix, maximum + 1) 1338 1339 # Add extension 1340 cfg = self.app.preferences['saving.default_format'] 1341 default_saveformat = self.config2saveformat[cfg] 1342 filename += self.saveformats[default_saveformat][1] 1343 1344 assert not os.path.exists(filename) 1345 if main_doc: 1346 self.save_file(filename) 1347 else: 1348 self.save_scratchpad(filename) 1349 return filename 1350 1351 def get_scrap_prefix(self): 1352 prefix = self.app.preferences['saving.scrap_prefix'] 1353 # This should really use two separate settings, not one. 1354 # https://github.com/mypaint/mypaint/issues/375 1355 prefix = fileutils.expanduser_unicode(prefix) 1356 prefix = os.path.abspath(prefix) 1357 if os.path.isdir(prefix): 1358 if not prefix.endswith(os.path.sep): 1359 prefix += os.path.sep 1360 return prefix 1361 1362 def get_scratchpad_prefix(self): 1363 # TODO allow override via prefs, maybe 1364 prefix = os.path.join(self.app.user_datapath, 'scratchpads') 1365 prefix = os.path.abspath(prefix) 1366 if os.path.isdir(prefix): 1367 if not prefix.endswith(os.path.sep): 1368 prefix += os.path.sep 1369 return prefix 1370 1371 def get_scratchpad_default(self): 1372 # TODO get the default name from preferences 1373 prefix = self.get_scratchpad_prefix() 1374 return os.path.join(prefix, "scratchpad_default.ora") 1375 1376 def get_scratchpad_autosave(self): 1377 prefix = self.get_scratchpad_prefix() 1378 return os.path.join(prefix, "autosave.ora") 1379 1380 def list_scraps(self): 1381 prefix = self.get_scrap_prefix() 1382 return self._list_prefixed_dir(prefix) 1383 1384 def list_scratchpads(self): 1385 prefix = self.get_scratchpad_prefix() 1386 files = self._list_prefixed_dir(prefix) 1387 special_prefix = os.path.join(prefix, "special") 1388 if os.path.isdir(special_prefix): 1389 files += self._list_prefixed_dir(special_prefix + os.path.sep) 1390 return files 1391 1392 def _list_prefixed_dir(self, prefix): 1393 filenames = [] 1394 for ext in ['png', 'ora', 'jpg', 'jpeg']: 1395 filenames += glob(prefix + '[0-9]*.' + ext) 1396 filenames += glob(prefix + '[0-9]*.' + ext.upper()) 1397 # For the special linked scratchpads 1398 filenames += glob(prefix + '_md5[0-9a-f]*.' + ext) 1399 filenames.sort() 1400 return filenames 1401 1402 def list_scraps_grouped(self): 1403 filenames = self.list_scraps() 1404 return self.list_files_grouped(filenames) 1405 1406 def list_scratchpads_grouped(self): 1407 filenames = self.list_scratchpads() 1408 return self.list_files_grouped(filenames) 1409 1410 def list_files_grouped(self, filenames): 1411 """return scraps grouped by their major number""" 1412 def scrap_id(filename): 1413 s = os.path.basename(filename) 1414 if s.startswith("_md5"): 1415 return s 1416 return re.findall('([0-9]+)', s)[0] 1417 groups = [] 1418 while filenames: 1419 group = [] 1420 sid = scrap_id(filenames[0]) 1421 while filenames and scrap_id(filenames[0]) == sid: 1422 group.append(filenames.pop(0)) 1423 groups.append(group) 1424 return groups 1425 1426 def open_recent_cb(self, action): 1427 """Callback for RecentAction""" 1428 uri = action.get_current_uri() 1429 fn, _h = lib.glib.filename_from_uri(uri) 1430 ok_to_open = self.app.filehandler.confirm_destructive_action( 1431 title = C_( 1432 u'File→Open Recent→* confirm dialog: title', 1433 u"Open File?" 1434 ), 1435 confirm = C_( 1436 u'File→Open Recent→* confirm dialog: continue button', 1437 u"_Open" 1438 ), 1439 ) 1440 if not ok_to_open: 1441 return 1442 self.open_file(fn) 1443 1444 def open_last_cb(self, action): 1445 """Callback to open the last file""" 1446 if not self._recent_items: 1447 return 1448 ok_to_open = self.app.filehandler.confirm_destructive_action( 1449 title = C_( 1450 u'File→Open Most Recent confirm dialog: ' 1451 u'title', 1452 u"Open Most Recent File?", 1453 ), 1454 confirm = C_( 1455 u'File→Open Most Recent→* confirm dialog: ' 1456 u'continue button', 1457 u"_Open" 1458 ), 1459 ) 1460 if not ok_to_open: 1461 return 1462 uri = self._recent_items.pop().get_uri() 1463 fn, _h = lib.glib.filename_from_uri(uri) 1464 self.open_file(fn) 1465 1466 def open_scrap_cb(self, action): 1467 groups = self.list_scraps_grouped() 1468 if not groups: 1469 msg = C_( 1470 'File→Open Next/Prev Scrap: error message', 1471 u"There are no scrap files yet. Try saving one first.", 1472 ) 1473 self.app.message_dialog(msg, message_type=Gtk.MessageType.WARNING) 1474 return 1475 next = action.get_name() == 'NextScrap' 1476 if next: 1477 dialog_title = C_( 1478 u'File→Open Next/Prev Scrap confirm dialog: ' 1479 u'title', 1480 u"Open Next Scrap?" 1481 ) 1482 idx = 0 1483 delta = 1 1484 else: 1485 dialog_title = C_( 1486 u'File→Open Next/Prev Scrap confirm dialog: ' 1487 u'title', 1488 u"Open Previous Scrap?" 1489 ) 1490 idx = -1 1491 delta = -1 1492 ok_to_open = self.app.filehandler.confirm_destructive_action( 1493 title = dialog_title, 1494 confirm = C_( 1495 u'File→Open Next/Prev Scrap confirm dialog: ' 1496 u'continue button', 1497 u"_Open" 1498 ), 1499 ) 1500 if not ok_to_open: 1501 return 1502 for i, group in enumerate(groups): 1503 if self.active_scrap_filename in group: 1504 idx = i + delta 1505 filename = groups[idx % len(groups)][-1] 1506 self.open_file(filename) 1507 1508 def reload_cb(self, action): 1509 if not self.filename: 1510 self.app.show_transient_message(C_( 1511 u'File→Revert: status message: canvas has no filename yet', 1512 u"Cannot revert: canvas has not been saved to a file yet.", 1513 )) 1514 return 1515 ok_to_reload = self.app.filehandler.confirm_destructive_action( 1516 title = C_( 1517 u'File→Revert confirm dialog: ' 1518 u'title', 1519 u"Revert Changes?", 1520 ), 1521 confirm = C_( 1522 u'File→Revert confirm dialog: ' 1523 u'continue button', 1524 u"_Revert" 1525 ), 1526 ) 1527 if ok_to_reload: 1528 self.open_file(self.filename) 1529 1530 def delete_scratchpads(self, filenames): 1531 prefix = self.get_scratchpad_prefix() 1532 prefix = os.path.abspath(prefix) 1533 for filename in filenames: 1534 if not (os.path.isfile(filename) and 1535 os.path.abspath(filename).startswith(prefix)): 1536 continue 1537 os.remove(filename) 1538 logger.info("Removed %s", filename) 1539 1540 def delete_default_scratchpad(self): 1541 if os.path.isfile(self.get_scratchpad_default()): 1542 os.remove(self.get_scratchpad_default()) 1543 logger.info("Removed the scratchpad default file") 1544 1545 def delete_autosave_scratchpad(self): 1546 if os.path.isfile(self.get_scratchpad_autosave()): 1547 os.remove(self.get_scratchpad_autosave()) 1548 logger.info("Removed the scratchpad autosave file") 1549 1550 def _recentfilter_func(self, rfinfo): 1551 """Recent-file filter function. 1552 1553 This does a filename extension check, and also verifies that the 1554 file actually exists. 1555 1556 """ 1557 if not rfinfo: 1558 return False 1559 apps = rfinfo.applications 1560 if not (apps and "mypaint" in apps): 1561 return False 1562 return self._uri_is_loadable(rfinfo.uri) 1563 # Keep this test in sync with _update_recent_items(). 1564 1565 def _uri_is_loadable(self, file_uri): 1566 """True if a URI is valid to be loaded by MyPaint.""" 1567 if file_uri is None: 1568 return False 1569 if not file_uri.startswith("file://"): 1570 return False 1571 file_path, _host = lib.glib.filename_from_uri(file_uri) 1572 if not os.path.exists(file_path): 1573 return False 1574 if not self._file_extension_regex.search(file_path): 1575 return False 1576 return True 1577