1 # This file is part of MyPaint.
2 # Copyright (C) 2007-2009 by Martin Renold <martinxyz@gmx.ch>
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.
14 from gettext import gettext as _
15 from gettext import ngettext
17 from lib import document, helpers
24 SAVE_FORMAT_PNGSOLID = 2
25 SAVE_FORMAT_PNGTRANS = 3
26 SAVE_FORMAT_PNGMULTI = 4
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)
37 def add_filters_to_dialog(filters, dialog):
38 for name, patterns in filters:
42 f.add_pattern(get_case_insensitive_glob(p))
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)
52 class FileHandler(object):
53 def __init__(self, app):
55 #NOTE: filehandling and drawwindow are very tightly coupled
56 self.save_dialog = None
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),
70 ag = gtk.ActionGroup('FileActions')
71 ag.add_actions(file_actions)
72 self.app.ui_manager.insert_action_group(ag, -1)
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')
80 ra.set_sort_type(gtk.RECENT_SORT_MRU)
81 ra.connect('item-activated', self.open_recent_cb)
84 for action in ag.list_actions():
85 self.app.kbm.takeover_action(action)
88 self.current_file_observers = []
89 self.active_scrap_filename = None
90 self.set_recent_items()
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")),
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
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,
117 def set_recent_items(self):
118 # this list is consumed in open_last_cb
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()))
128 self.recent_items.reverse()
130 def get_filename(self):
131 return self._filename
133 def set_filename(self, value):
134 self._filename = value
135 for f in self.current_file_observers:
139 if self.filename.startswith(self.get_scrap_prefix()):
140 self.active_scrap_filename = self.filename
142 filename = property(get_filename, set_filename)
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)
154 # Add widget for selecting save format
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)
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)
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()
174 filename = filename.decode('utf-8')
175 filename, ext = os.path.splitext(filename)
177 saveformat = self.saveformat_combo.get_active()
178 ext = self.saveformats[saveformat][1]
180 dialog_set_filename(dialog, filename+ext)
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)
191 t = ngettext('%d minute', '%d minutes', t) % t
194 t = ngettext('%d second', '%d seconds', t) % t
195 d = gtk.Dialog(title, self.app.drawWindow, gtk.DIALOG_MODAL)
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))
203 d.set_has_separator(False)
204 d.set_default_response(gtk.RESPONSE_CANCEL)
206 l.set_markup(_("<b>%s</b>\n\nThis will discard %s of unsaved painting.") % (question,t))
207 l.set_padding(10, 10)
212 if response == gtk.RESPONSE_APPLY:
213 self.save_scrap_cb(None)
215 return response == gtk.RESPONSE_OK
217 def new_cb(self, action):
218 if not self.confirm_destructive_action():
220 bg = self.doc.model.background
221 self.doc.model.clear()
222 self.doc.model.set_background(bg)
224 self.set_recent_items()
225 self.app.doc.reset_view_cb(None)
229 while gtk.events_pending():
230 gtk.main_iteration(False)
232 @drawwindow.with_wait_cursor
233 def open_file(self, filename):
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)
239 self.filename = os.path.abspath(filename)
240 print 'Loaded from', self.filename
241 self.app.doc.reset_view_cb(None)
243 @drawwindow.with_wait_cursor
244 def save_file(self, filename, export=False, **options):
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)
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),
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'
266 file_location = os.path.abspath(filename)
267 print 'Exported to', os.path.abspath(file_location)
269 if not thumbnail_pixbuf:
270 thumbnail_pixbuf = self.doc.model.render_thumbnail()
271 helpers.freedesktop_thumbnail(file_location, thumbnail_pixbuf)
273 def update_preview_cb(self, file_chooser, preview):
274 filename = file_chooser.get_preview_filename()
276 filename = filename.decode('utf-8')
277 pixbuf = helpers.freedesktop_thumbnail(filename)
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)
284 #TODO display "no preview available" image
287 def open_cb(self, action):
288 if not self.confirm_destructive_action():
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)
296 preview = gtk.Image()
297 dialog.set_preview_widget(preview)
298 dialog.connect("update-preview", self.update_preview_cb, preview)
300 add_filters_to_dialog(self.file_filters, dialog)
303 dialog.set_filename(self.filename)
305 # choose the most recent save folder
306 self.set_recent_items()
307 for item in reversed(self.recent_items):
309 fn = helpers.uri2filename(uri)
310 dn = os.path.dirname(fn)
311 if os.path.isdir(dn):
312 dialog.set_current_folder(dn)
315 if dialog.run() == gtk.RESPONSE_OK:
317 self.open_file(dialog.get_filename().decode('utf-8'))
321 def save_cb(self, action):
322 if not self.filename:
323 self.save_as_cb(action)
325 self.save_file(self.filename)
327 def save_as_cb(self, action):
328 if not self.save_dialog:
329 self.init_save_dialog()
330 dialog = self.save_dialog
332 dialog_set_filename(dialog, self.filename)
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):
339 fn = helpers.uri2filename(uri)
340 dn = os.path.dirname(fn)
341 if os.path.isdir(dn):
342 dialog.set_current_folder(dn)
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()
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]
358 saveformat = self.ext2saveformat[ext]
360 saveformat = default_saveformat
362 saveformat = default_saveformat
364 desc, ext_format, options = self.saveformats[saveformat]
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.
376 if action.get_name() == 'Export':
377 # Do not change working file
378 self.save_file(filename, True, **options)
380 self.save_file(filename, **options)
383 filename = name + ext_format
385 # trigger overwrite confirmation for the modified filename
386 dialog_set_filename(dialog, filename)
387 dialog.response(gtk.RESPONSE_OK)
391 dialog.destroy() # avoid GTK crash: https://gna.org/bugs/?17902
392 self.save_dialog = None
394 def save_scrap_cb(self, action):
395 filename = self.filename
396 prefix = self.get_scrap_prefix()
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)
405 l = re.findall(re.escape(prefix) + '([0-9]+)', filename)
410 # reuse the number, find the next character
412 for filename in glob(prefix + number + '_*'):
413 c = filename[len(prefix + number + '_')]
414 if c >= 'a' and c <= 'z' and c >= char:
417 # out of characters, increase the number
419 return self.save_scrap_cb(action)
420 filename = '%s%s_%c' % (prefix, number, char)
422 # we don't have a scrap filename yet, find the next number
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)
431 filename = '%s%03d_a' % (prefix, maximum+1)
434 cfg = self.app.preferences['saving.default_format']
435 default_saveformat = self.config2saveformat[cfg]
436 filename += self.saveformats[default_saveformat][1]
438 assert not os.path.exists(filename)
439 self.save_file(filename)
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
450 def list_scraps(self):
451 prefix = self.get_scrap_prefix()
453 for ext in ['png', 'ora', 'jpg', 'jpeg']:
454 filenames += glob(prefix + '[0-9]*.' + ext)
455 filenames += glob(prefix + '[0-9]*.' + ext.upper())
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()
468 sid = scrap_id(filenames[0])
469 while filenames and scrap_id(filenames[0]) == sid:
470 group.append(filenames.pop(0))
474 def open_recent_cb(self, action):
475 """Callback for RecentAction"""
476 if not self.confirm_destructive_action():
478 uri = action.get_current_uri()
479 fn = helpers.uri2filename(uri)
482 def open_last_cb(self, action):
483 """Callback to open the last file"""
484 if not self.recent_items:
486 if not self.confirm_destructive_action():
488 uri = self.recent_items.pop().get_uri()
489 fn = helpers.uri2filename(uri)
492 def open_scrap_cb(self, action):
493 groups = self.list_scraps_grouped()
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)
499 if not self.confirm_destructive_action():
501 next = action.get_name() == 'NextScrap'
505 for i, group in enumerate(groups):
506 if self.active_scrap_filename in group:
509 filename = groups[idx%len(groups)][-1]
510 self.open_file(filename)
512 def reload_cb(self, action):
513 if self.filename and self.confirm_destructive_action():
514 self.open_file(self.filename)