OSDN Git Service

saving: ask for confirmation for >=8s of work
[mypaint-anime/master.git] / gui / filehandling.py
1 # This file is part of MyPaint.
2 # Copyright (C) 2007-2009 by Martin Renold <martinxyz@gmx.ch>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8
9 import os, re
10 from glob import glob
11 import sys
12
13 import gtk
14 from gettext import gettext as _
15 from gettext import ngettext
16
17 from lib import document, helpers
18 import drawwindow
19
20 import mimetypes
21
22 SAVE_FORMAT_ANY = 0
23 SAVE_FORMAT_ORA = 1
24 SAVE_FORMAT_PNGSOLID = 2
25 SAVE_FORMAT_PNGTRANS = 3
26 SAVE_FORMAT_PNGMULTI = 4
27 SAVE_FORMAT_JPEG = 5
28
29 # Utility function to work around the fact that gtk FileChooser/FileFilter
30 # does not have an easy way to use case insensitive filters
31 def get_case_insensitive_glob(string):
32     '''Ex: '*.ora' => '*.[oO][rR][aA]' '''
33     ext = string.split('.')[1]
34     globlist = ["[%s%s]" % (c.lower(), c.upper()) for c in ext]
35     return '*.%s' % ''.join(globlist)
36
37 def add_filters_to_dialog(filters, dialog):
38     for name, patterns in filters:
39         f = gtk.FileFilter()
40         f.set_name(name)
41         for p in patterns:
42             f.add_pattern(get_case_insensitive_glob(p))
43         dialog.add_filter(f)
44
45 def dialog_set_filename(dialog, s):
46     # According to pygtk docu we should use set_filename(),
47     # however doing so removes the selected filefilter.
48     path, name = os.path.split(s)
49     dialog.set_current_folder(path)
50     dialog.set_current_name(name)
51
52 class FileHandler(object):
53     def __init__(self, app):
54         self.app = app
55         #NOTE: filehandling and drawwindow are very tightly coupled
56         self.save_dialog = None
57
58         file_actions = [ \
59         ('New',          gtk.STOCK_NEW, _('New'), '<control>N', None, self.new_cb),
60         ('Open',         gtk.STOCK_OPEN, _('Open...'), '<control>O', None, self.open_cb),
61         ('OpenLast',     None, _('Open Last'), 'F3', None, self.open_last_cb),
62         ('Reload',       gtk.STOCK_REFRESH, _('Reload'), 'F5', None, self.reload_cb),
63         ('Save',         gtk.STOCK_SAVE, _('Save'), '<control>S', None, self.save_cb),
64         ('SaveAs',       gtk.STOCK_SAVE_AS, _('Save As...'), '<control><shift>S', None, self.save_as_cb),
65         ('Export',       gtk.STOCK_SAVE_AS, _('Export...'), '<control><shift>E', None, self.save_as_cb),
66         ('SaveScrap',    None, _('Save As Scrap'), 'F2', None, self.save_scrap_cb),
67         ('PrevScrap',    None, _('Open Previous Scrap'), 'F6', None, self.open_scrap_cb),
68         ('NextScrap',    None, _('Open Next Scrap'), 'F7', None, self.open_scrap_cb),
69         ]
70         ag = gtk.ActionGroup('FileActions')
71         ag.add_actions(file_actions)
72         self.app.ui_manager.insert_action_group(ag, -1)
73
74         ra = gtk.RecentAction('OpenRecent', _('Open Recent'), _('Open Recent files'), None)
75         ra.set_show_tips(True)
76         ra.set_show_numbers(True)
77         rf = gtk.RecentFilter()
78         rf.add_application('mypaint')
79         ra.add_filter(rf)
80         ra.set_sort_type(gtk.RECENT_SORT_MRU)
81         ra.connect('item-activated', self.open_recent_cb)
82         ag.add_action(ra)
83
84         for action in ag.list_actions():
85             self.app.kbm.takeover_action(action)
86
87         self._filename = None
88         self.current_file_observers = []
89         self.active_scrap_filename = None
90         self.set_recent_items()
91
92         self.file_filters = [ #(name, patterns)
93         (_("All Recognized Formats"), ("*.ora", "*.png", "*.jpg", "*.jpeg")),
94         (_("OpenRaster (*.ora)"), ("*.ora",)),
95         (_("PNG (*.png)"), ("*.png",)),
96         (_("JPEG (*.jpg; *.jpeg)"), ("*.jpg", "*.jpeg")),
97         ]
98         self.saveformats = [ #(name, extension, options)
99         (_("By extension (prefer default format)"), None, {}), #0
100         (_("OpenRaster (*.ora)"), '.ora', {}), #1
101         (_("PNG solid with background (*.png)"), '.png', {'alpha': False}), #2
102         (_("PNG transparent (*.png)"), '.png', {'alpha': True}), #3
103         (_("Multiple PNG transparent (*.XXX.png)"), '.png', {'multifile': True}), #4
104         (_("JPEG 90% quality (*.jpg; *.jpeg)"), '.jpg', {'quality': 90}), #5
105         ]
106         self.ext2saveformat = {
107         '.ora': SAVE_FORMAT_ORA, 
108         '.png': SAVE_FORMAT_PNGSOLID, 
109         '.jpeg': SAVE_FORMAT_JPEG, 
110         '.jpg': SAVE_FORMAT_JPEG}
111         self.config2saveformat = {
112         'openraster': SAVE_FORMAT_ORA,
113         'jpeg-90%': SAVE_FORMAT_JPEG,
114         'png-solid': SAVE_FORMAT_PNGSOLID,
115         }
116
117     def set_recent_items(self):
118         # this list is consumed in open_last_cb
119
120         # Note: i.exists() does not work on Windows if the pathname
121         # contains utf-8 characters. Since GIMP also saves its URIs
122         # with utf-8 characters into this list, I assume this is a
123         # gtk bug.  So we use our own test instead of i.exists().
124         self.recent_items = [
125                 i for i in gtk.recent_manager_get_default().get_items()
126                 if "mypaint" in i.get_applications() and os.path.exists(helpers.uri2filename(i.get_uri()))
127         ]
128         self.recent_items.reverse()
129
130     def get_filename(self):
131         return self._filename
132
133     def set_filename(self, value):
134         self._filename = value
135         for f in self.current_file_observers:
136             f(self.filename)
137
138         if self.filename:
139             if self.filename.startswith(self.get_scrap_prefix()):
140                 self.active_scrap_filename = self.filename
141
142     filename = property(get_filename, set_filename)
143
144     def init_save_dialog(self):
145         dialog = gtk.FileChooserDialog(_("Save..."), self.app.drawWindow,
146                                        gtk.FILE_CHOOSER_ACTION_SAVE,
147                                        (gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL,
148                                         gtk.STOCK_SAVE, gtk.RESPONSE_OK))
149         self.save_dialog = dialog
150         dialog.set_default_response(gtk.RESPONSE_OK)
151         dialog.set_do_overwrite_confirmation(True)
152         add_filters_to_dialog(self.file_filters, dialog)
153
154         # Add widget for selecting save format
155         box = gtk.HBox()
156         label = gtk.Label(_('Format to save as:'))
157         label.set_alignment(0.0, 0.0)
158         combo = self.saveformat_combo = gtk.combo_box_new_text()
159         for name, ext, opt in self.saveformats:
160             combo.append_text(name)
161         combo.set_active(0)
162         combo.connect('changed', self.selected_save_format_changed_cb)
163         box.pack_start(label)
164         box.pack_start(combo, expand=False)
165         dialog.set_extra_widget(box)
166         dialog.show_all()
167
168     def selected_save_format_changed_cb(self, widget):
169         """When the user changes the selected format to save as in the dialog, 
170         change the extension of the filename (if existing) immediately."""
171         dialog = self.save_dialog
172         filename = dialog.get_filename()
173         if filename:
174             filename = filename.decode('utf-8')
175             filename, ext = os.path.splitext(filename)
176             if ext:
177                 saveformat = self.saveformat_combo.get_active()
178                 ext = self.saveformats[saveformat][1]
179                 if ext is not None:
180                     dialog_set_filename(dialog, filename+ext)
181
182     def confirm_destructive_action(self, title=_('Confirm'), question=_('Really continue?')):
183         self.doc.model.split_stroke() # finish stroke in progress
184         t = self.doc.model.unsaved_painting_time
185         # enough changes to bother asking? (useful for fast develop-and-test)
186         if t < 8: # (used to be 30, see https://gna.org/bugs/?17955)
187             return True
188
189         if t > 120:
190             t = int(round(t/60))
191             t = ngettext('%d minute', '%d minutes', t) % t
192         else:
193             t = int(round(t))
194             t = ngettext('%d second', '%d seconds', t) % t
195         d = gtk.Dialog(title, self.app.drawWindow, gtk.DIALOG_MODAL)
196
197         b = d.add_button(gtk.STOCK_DISCARD, gtk.RESPONSE_OK)
198         b.set_image(gtk.image_new_from_stock(gtk.STOCK_DELETE, gtk.ICON_SIZE_BUTTON))
199         d.add_button(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
200         b = d.add_button(_("_Save as Scrap"), gtk.RESPONSE_APPLY)
201         b.set_image(gtk.image_new_from_stock(gtk.STOCK_SAVE, gtk.ICON_SIZE_BUTTON))
202
203         d.set_has_separator(False)
204         d.set_default_response(gtk.RESPONSE_CANCEL)
205         l = gtk.Label()
206         l.set_markup(_("<b>%s</b>\n\nThis will discard %s of unsaved painting.") % (question,t))
207         l.set_padding(10, 10)
208         l.show()
209         d.vbox.pack_start(l)
210         response = d.run()
211         d.destroy()
212         if response == gtk.RESPONSE_APPLY:
213             self.save_scrap_cb(None)
214             return True
215         return response == gtk.RESPONSE_OK
216
217     def new_cb(self, action):
218         if not self.confirm_destructive_action():
219             return
220         bg = self.doc.model.background
221         self.doc.model.clear()
222         self.doc.model.set_background(bg)
223         self.filename = None
224         self.set_recent_items()
225         self.app.doc.reset_view_cb(None)
226
227     @staticmethod
228     def gtk_main_tick():
229         while gtk.events_pending():
230             gtk.main_iteration(False)
231
232     @drawwindow.with_wait_cursor
233     def open_file(self, filename):
234         try:
235             self.doc.model.load(filename, feedback_cb=self.gtk_main_tick)
236         except document.SaveLoadError, e:
237             self.app.message_dialog(str(e),type=gtk.MESSAGE_ERROR)
238         else:
239             self.filename = os.path.abspath(filename)
240             print 'Loaded from', self.filename
241             self.app.doc.reset_view_cb(None)
242
243     @drawwindow.with_wait_cursor
244     def save_file(self, filename, export=False, **options):
245         try:
246             x, y, w, h =  self.doc.model.get_bbox()
247             if w == 0 and h == 0:
248                 raise document.SaveLoadError, _('Did not save, the canvas is empty.')
249             thumbnail_pixbuf = self.doc.model.save(filename, feedback_cb=self.gtk_main_tick, **options)
250         except document.SaveLoadError, e:
251             self.app.message_dialog(str(e),type=gtk.MESSAGE_ERROR)
252         else:
253             file_location = None
254             if not export:
255                 file_location = self.filename = os.path.abspath(filename)
256                 print 'Saved to', self.filename
257                 gtk.recent_manager_get_default().add_full(helpers.filename2uri(self.filename),
258                         {
259                             'app_name': 'mypaint',
260                             'app_exec': sys.argv_unicode[0].encode('utf-8'),
261                             # todo: get mime_type
262                             'mime_type': 'application/octet-stream'
263                         }
264                 )
265             else:
266                 file_location = os.path.abspath(filename)
267                 print 'Exported to', os.path.abspath(file_location)
268
269             if not thumbnail_pixbuf:
270                 thumbnail_pixbuf = self.doc.model.render_thumbnail()
271             helpers.freedesktop_thumbnail(file_location, thumbnail_pixbuf)
272
273     def update_preview_cb(self, file_chooser, preview):
274         filename = file_chooser.get_preview_filename()
275         if filename:
276             filename = filename.decode('utf-8')
277             pixbuf = helpers.freedesktop_thumbnail(filename)
278             if pixbuf:
279                 # if pixbuf is smaller than 256px in width, copy it onto a transparent 256x256 pixbuf
280                 pixbuf = helpers.pixbuf_thumbnail(pixbuf, 256, 256, True)
281                 preview.set_from_pixbuf(pixbuf)
282                 file_chooser.set_preview_widget_active(True)
283             else:
284                 #TODO display "no preview available" image
285                 pass
286
287     def open_cb(self, action):
288         if not self.confirm_destructive_action():
289             return
290         dialog = gtk.FileChooserDialog(_("Open..."), self.app.drawWindow,
291                                        gtk.FILE_CHOOSER_ACTION_OPEN,
292                                        (gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL,
293                                         gtk.STOCK_OPEN, gtk.RESPONSE_OK))
294         dialog.set_default_response(gtk.RESPONSE_OK)
295
296         preview = gtk.Image()
297         dialog.set_preview_widget(preview)
298         dialog.connect("update-preview", self.update_preview_cb, preview)
299
300         add_filters_to_dialog(self.file_filters, dialog)
301
302         if self.filename:
303             dialog.set_filename(self.filename)
304         else:
305             # choose the most recent save folder
306             self.set_recent_items()
307             for item in reversed(self.recent_items):
308                 uri = item.get_uri()
309                 fn = helpers.uri2filename(uri)
310                 dn = os.path.dirname(fn)
311                 if os.path.isdir(dn):
312                     dialog.set_current_folder(dn)
313                     break
314         try:
315             if dialog.run() == gtk.RESPONSE_OK:
316                 dialog.hide()
317                 self.open_file(dialog.get_filename().decode('utf-8'))
318         finally:
319             dialog.destroy()
320
321     def save_cb(self, action):
322         if not self.filename:
323             self.save_as_cb(action)
324         else:
325             self.save_file(self.filename)
326
327     def save_as_cb(self, action):
328         if not self.save_dialog:
329             self.init_save_dialog()
330         dialog = self.save_dialog
331         if self.filename:
332             dialog_set_filename(dialog, self.filename)
333         else:
334             dialog_set_filename(dialog, '')
335             # choose the most recent save folder
336             self.set_recent_items()
337             for item in reversed(self.recent_items):
338                 uri = item.get_uri()
339                 fn = helpers.uri2filename(uri)
340                 dn = os.path.dirname(fn)
341                 if os.path.isdir(dn):
342                     dialog.set_current_folder(dn)
343                     break
344
345         try:
346             # Loop until we have filename with an extension
347             while dialog.run() == gtk.RESPONSE_OK:
348                 filename = dialog.get_filename().decode('utf-8')
349                 name, ext = os.path.splitext(filename)
350                 saveformat = self.saveformat_combo.get_active()
351
352                 # If no explicitly selected format, use the extension to figure it out
353                 if saveformat == SAVE_FORMAT_ANY:
354                     cfg = self.app.preferences['saving.default_format']
355                     default_saveformat = self.config2saveformat[cfg]
356                     if ext:
357                         try: 
358                             saveformat = self.ext2saveformat[ext]
359                         except KeyError:
360                             saveformat = default_saveformat
361                     else:
362                             saveformat = default_saveformat
363
364                 desc, ext_format, options = self.saveformats[saveformat]
365
366                 # 
367                 if ext:
368                     if ext_format != ext:
369                         # Minor ugliness: if the user types '.png' but
370                         # leaves the default .ora filter selected, we
371                         # use the default options instead of those
372                         # above. However, they are the same at the moment.
373                         options = {}
374                     assert(filename)
375                     dialog.hide()
376                     if action.get_name() == 'Export':
377                         # Do not change working file
378                         self.save_file(filename, True, **options)
379                     else:
380                         self.save_file(filename, **options)
381                     break
382
383                 filename = name + ext_format
384
385                 # trigger overwrite confirmation for the modified filename
386                 dialog_set_filename(dialog, filename)
387                 dialog.response(gtk.RESPONSE_OK)
388
389         finally:
390             dialog.hide()
391             dialog.destroy()  # avoid GTK crash: https://gna.org/bugs/?17902
392             self.save_dialog = None
393
394     def save_scrap_cb(self, action):
395         filename = self.filename
396         prefix = self.get_scrap_prefix()
397
398         # If necessary, create the folder(s) the scraps are stored under
399         prefix_dir = os.path.dirname(prefix)
400         if not os.path.exists(prefix_dir): 
401             os.makedirs(prefix_dir)
402
403         number = None
404         if filename:
405             l = re.findall(re.escape(prefix) + '([0-9]+)', filename)
406             if l:
407                 number = l[0]
408
409         if number:
410             # reuse the number, find the next character
411             char = 'a'
412             for filename in glob(prefix + number + '_*'):
413                 c = filename[len(prefix + number + '_')]
414                 if c >= 'a' and c <= 'z' and c >= char:
415                     char = chr(ord(c)+1)
416             if char > 'z':
417                 # out of characters, increase the number
418                 self.filename = None
419                 return self.save_scrap_cb(action)
420             filename = '%s%s_%c' % (prefix, number, char)
421         else:
422             # we don't have a scrap filename yet, find the next number
423             maximum = 0
424             for filename in glob(prefix + '[0-9][0-9][0-9]*'):
425                 filename = filename[len(prefix):]
426                 res = re.findall(r'[0-9]*', filename)
427                 if not res: continue
428                 number = int(res[0])
429                 if number > maximum:
430                     maximum = number
431             filename = '%s%03d_a' % (prefix, maximum+1)
432
433         # Add extension
434         cfg = self.app.preferences['saving.default_format']
435         default_saveformat = self.config2saveformat[cfg]
436         filename += self.saveformats[default_saveformat][1]
437
438         assert not os.path.exists(filename)
439         self.save_file(filename)
440
441     def get_scrap_prefix(self):
442         prefix = self.app.preferences['saving.scrap_prefix']
443         prefix = helpers.expanduser_unicode(prefix.decode('utf-8'))
444         prefix = os.path.abspath(prefix)
445         if os.path.isdir(prefix):
446             if not prefix.endswith(os.path.sep):
447                 prefix += os.path.sep
448         return prefix
449
450     def list_scraps(self):
451         prefix = self.get_scrap_prefix()
452         filenames = []
453         for ext in ['png', 'ora', 'jpg', 'jpeg']:
454             filenames += glob(prefix + '[0-9]*.' + ext)
455             filenames += glob(prefix + '[0-9]*.' + ext.upper())
456         filenames.sort()
457         return filenames
458
459     def list_scraps_grouped(self):
460         """return scraps grouped by their major number"""
461         def scrap_id(filename):
462             s = os.path.basename(filename)
463             return re.findall('([0-9]+)', s)[0]
464         filenames = self.list_scraps()
465         groups = []
466         while filenames:
467             group = []
468             sid = scrap_id(filenames[0])
469             while filenames and scrap_id(filenames[0]) == sid:
470                 group.append(filenames.pop(0))
471             groups.append(group)
472         return groups
473
474     def open_recent_cb(self, action):
475         """Callback for RecentAction"""
476         if not self.confirm_destructive_action():
477             return
478         uri = action.get_current_uri()
479         fn = helpers.uri2filename(uri)
480         self.open_file(fn)
481
482     def open_last_cb(self, action):
483         """Callback to open the last file"""
484         if not self.recent_items:
485             return
486         if not self.confirm_destructive_action():
487             return
488         uri = self.recent_items.pop().get_uri()
489         fn = helpers.uri2filename(uri)
490         self.open_file(fn)
491
492     def open_scrap_cb(self, action):
493         groups = self.list_scraps_grouped()
494         if not groups:
495             msg = _('There are no scrap files named "%s" yet.') % \
496                 (self.get_scrap_prefix() + '[0-9]*')
497             self.app.message_dialog(msg, gtk.MESSAGE_WARNING)
498             return
499         if not self.confirm_destructive_action():
500             return
501         next = action.get_name() == 'NextScrap'
502
503         if next: idx = 0
504         else:    idx = -1
505         for i, group in enumerate(groups):
506             if self.active_scrap_filename in group:
507                 if next: idx = i + 1
508                 else:    idx = i - 1
509         filename = groups[idx%len(groups)][-1]
510         self.open_file(filename)
511
512     def reload_cb(self, action):
513         if self.filename and self.confirm_destructive_action():
514             self.open_file(self.filename)