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