Correctly call mime.image_for_type now.
[rox-lib.git] / ROX-Lib2 / python / rox / saving.py
blob283f65dacb3c19e1b68093569ec2c71dfe6a1163
1 """All ROX applications that can save documents should use drag-and-drop saving.
2 The document itself should use the Saveable mix-in class and override some of the
3 methods to actually do the save.
5 If you want to save a selection then you can create a new object specially for
6 the purpose and pass that to the SaveBox."""
8 import os, sys
9 import rox
10 from rox import alert, g, _, filer, escape
11 from rox import choices, get_local_path, basedir, mime
13 gdk = g.gdk
15 TARGET_XDS = 0
16 TARGET_RAW = 1
18 def _chmod(path, mode):
19 """Like os.chmod, except that permission denied errors are not fatal
20 (for FAT partitions)."""
21 try:
22 os.chmod(path, mode)
23 except OSError, ex:
24 if ex.errno != 1:
25 raise
26 # Log the error and continue.
27 print >>sys.stderr, "Warning: Failed to set permissions:", ex
29 def _write_xds_property(context, value):
30 win = context.source_window
31 if value:
32 win.property_change('XdndDirectSave0', 'text/plain', 8,
33 gdk.PROP_MODE_REPLACE,
34 value)
35 else:
36 win.property_delete('XdndDirectSave0')
38 def _read_xds_property(context, delete):
39 """Returns a UTF-8 encoded, non-escaped, URI."""
40 win = context.source_window
41 retval = win.property_get('XdndDirectSave0', 'text/plain', delete)
42 if retval:
43 return retval[2]
44 return None
46 def image_for_type(type, size=48, flags=0):
47 """See mime.image_for_type"""
48 return mime.image_for_type(type, size, flags)
50 def _report_save_error():
51 "Report a AbortSave nicely, otherwise use report_exception()"
52 value = sys.exc_info()[1]
53 if isinstance(value, AbortSave):
54 value.show()
55 else:
56 rox.report_exception()
58 class AbortSave(rox.UserAbort):
59 """Raise this to cancel a save. If a message is given, it is displayed
60 in a normal alert box (not in the report_exception style). If the
61 message is None, no message is shown (you should have already shown
62 it!)"""
63 def __init__(self, message):
64 self.message = message
65 rox.UserAbort.__init__(self, message)
67 def show(self):
68 if self.message:
69 rox.alert(self.message)
71 class Saveable:
72 """This class describes the interface that an object must provide
73 to work with the SaveBox/SaveArea widgets. Inherit from it if you
74 want to save. All methods can be overridden, but normally only
75 save_to_stream() needs to be. You can also set save_last_stat to
76 the result of os.stat(filename) when loading a file to make ROX-Lib
77 restore permissions and warn about other programs editing the file."""
79 save_last_stat = None
81 def set_uri(self, uri):
82 """When the data is safely saved somewhere this is called
83 with its new name. Mark your data as unmodified and update
84 the filename for next time. Saving to another application
85 won't call this method. Default method does nothing.
86 The URI may be in the form of a URI or a local path.
87 It is UTF-8, not escaped (% really means %)."""
88 pass
90 def save_to_stream(self, stream):
91 """Write the data to save to the stream. When saving to a
92 local file, stream will be the actual file, otherwise it is a
93 cStringIO object."""
94 raise Exception('You forgot to write the save_to_stream() method...'
95 'silly programmer!')
97 def save_to_file(self, path):
98 """Write data to file. Raise an exception on error.
99 The default creates a temporary file, uses save_to_stream() to
100 write to it, then renames it over the original. If the temporary file
101 can't be created, it writes directly over the original."""
103 # Ensure the directory exists...
104 parent_dir = os.path.dirname(path)
105 if not os.path.isdir(parent_dir):
106 from rox import fileutils
107 try:
108 fileutils.makedirs(parent_dir)
109 except OSError:
110 raise AbortSave(None) # (message already shown)
112 import random
113 tmp = 'tmp-' + `random.randrange(1000000)`
114 tmp = os.path.join(parent_dir, tmp)
116 def open_private(path):
117 return os.fdopen(os.open(path, os.O_CREAT | os.O_WRONLY, 0600), 'wb')
119 try:
120 stream = open_private(tmp)
121 except:
122 # Can't create backup... try a direct write
123 tmp = None
124 stream = open_private(path)
125 try:
126 try:
127 self.save_to_stream(stream)
128 finally:
129 stream.close()
130 if tmp:
131 os.rename(tmp, path)
132 except:
133 _report_save_error()
134 if tmp and os.path.exists(tmp):
135 if os.path.getsize(tmp) == 0 or \
136 rox.confirm(_("Delete temporary file '%s'?") % tmp,
137 g.STOCK_DELETE):
138 os.unlink(tmp)
139 raise AbortSave(None)
140 self.save_set_permissions(path)
141 filer.examine(path)
143 def save_to_selection(self, selection_data):
144 """Write data to the selection. The default method uses save_to_stream()."""
145 from cStringIO import StringIO
146 stream = StringIO()
147 self.save_to_stream(stream)
148 selection_data.set(selection_data.target, 8, stream.getvalue())
150 save_mode = None # For backwards compat
151 def save_set_permissions(self, path):
152 """The default save_to_file() creates files with the mode 0600
153 (user read/write only). After saving has finished, it calls this
154 method to set the final permissions. The save_set_permissions():
155 - sets it to 0666 masked with the umask (if save_mode is None), or
156 - sets it to save_last_stat.st_mode (not masked) otherwise."""
157 if self.save_last_stat is not None:
158 save_mode = self.save_last_stat.st_mode
159 else:
160 save_mode = self.save_mode
162 if save_mode is not None:
163 _chmod(path, save_mode)
164 else:
165 mask = os.umask(0077) # Get the current umask
166 os.umask(mask) # Set it back how it was
167 _chmod(path, 0666 & ~mask)
169 def save_done(self):
170 """Time to close the savebox. Default method does nothing."""
171 pass
173 def discard(self):
174 """Discard button clicked, or document safely saved. Only called if a SaveBox
175 was created with discard=1.
176 The user doesn't want the document any more, even if it's modified and unsaved.
177 Delete it."""
178 raise Exception("Sorry... my programmer forgot to tell me how to handle Discard!")
180 save_to_stream._rox_default = 1
181 save_to_file._rox_default = 1
182 save_to_selection._rox_default = 1
183 def can_save_to_file(self):
184 """Indicates whether we have a working save_to_stream or save_to_file
185 method (ie, whether we can save to files). Default method checks that
186 one of these two methods has been overridden."""
187 if not hasattr(self.save_to_stream, '_rox_default'):
188 return 1 # Have user-provided save_to_stream
189 if not hasattr(self.save_to_file, '_rox_default'):
190 return 1 # Have user-provided save_to_file
191 return 0
192 def can_save_to_selection(self):
193 """Indicates whether we have a working save_to_stream or save_to_selection
194 method (ie, whether we can save to selections). Default methods checks that
195 one of these two methods has been overridden."""
196 if not hasattr(self.save_to_stream, '_rox_default'):
197 return 1 # Have user-provided save_to_stream
198 if not hasattr(self.save_to_selection, '_rox_default'):
199 return 1 # Have user-provided save_to_file
200 return 0
202 def save_cancelled(self):
203 """If you multitask during a save (using a recursive mainloop) then the
204 user may click on the Cancel button. This function gets called if so, and
205 should cause the recursive mainloop to return."""
206 raise Exception("Lazy programmer error: can't abort save!")
208 class SaveArea(g.VBox):
209 """A SaveArea contains the widgets used in a save box. You can use
210 this to put a savebox area in a larger window."""
212 document = None # The Saveable with the data
213 entry = None
214 initial_uri = None # The pathname supplied to the constructor
216 def __init__(self, document, uri, type):
217 """'document' must be a subclass of Saveable.
218 'uri' is the file's current location, or a simple name (eg 'TextFile')
219 if it has never been saved.
220 'type' is the MIME-type to use (eg 'text/plain').
222 g.VBox.__init__(self, False, 0)
224 self.document = document
225 self.initial_uri = uri
227 drag_area = self._create_drag_area(type)
228 self.pack_start(drag_area, True, True, 0)
229 drag_area.show_all()
231 entry = g.Entry()
232 entry.connect('activate', lambda w: self.save_to_file_in_entry())
233 self.entry = entry
234 self.pack_start(entry, False, True, 4)
235 entry.show()
237 entry.set_text(uri)
239 def _set_icon(self, type):
240 pixbuf = mime.image_for_type(type)
241 if pixbuf:
242 self.icon.set_from_pixbuf(pixbuf)
243 else:
244 self.icon.set_from_stock(g.STOCK_MISSING_IMAGE, g.ICON_SIZE_DND)
246 def _create_drag_area(self, type):
247 align = g.Alignment()
248 align.set(.5, .5, 0, 0)
250 self.drag_box = g.EventBox()
251 self.drag_box.set_border_width(4)
252 self.drag_box.add_events(gdk.BUTTON_PRESS_MASK)
253 align.add(self.drag_box)
255 self.icon = g.Image()
256 self._set_icon(type)
258 self._set_drag_source(type)
259 self.drag_box.connect('drag_begin', self.drag_begin)
260 self.drag_box.connect('drag_end', self.drag_end)
261 self.drag_box.connect('drag_data_get', self.drag_data_get)
262 self.drag_in_progress = 0
264 self.drag_box.add(self.icon)
266 return align
268 def set_type(self, type, icon = None):
269 """Change the icon and drag target to 'type'.
270 If 'icon' is given (as a GtkImage) then that icon is used,
271 otherwise an appropriate icon for the type is used."""
272 if icon:
273 self.icon.set_from_pixbuf(icon.get_pixbuf())
274 else:
275 self._set_icon(type)
276 self._set_drag_source(type)
278 def _set_drag_source(self, type):
279 if self.document.can_save_to_file():
280 targets = [('XdndDirectSave0', 0, TARGET_XDS)]
281 else:
282 targets = []
283 if self.document.can_save_to_selection():
284 targets = targets + [(type, 0, TARGET_RAW),
285 ('application/octet-stream', 0, TARGET_RAW)]
287 if not targets:
288 raise Exception("Document %s can't save!" % self.document)
289 self.drag_box.drag_source_set(gdk.BUTTON1_MASK | gdk.BUTTON3_MASK,
290 targets,
291 gdk.ACTION_COPY | gdk.ACTION_MOVE)
293 def save_to_file_in_entry(self):
294 """Call this when the user clicks on an OK button you provide."""
295 uri = self.entry.get_text()
296 path = get_local_path(escape(uri))
298 if path:
299 if not self.confirm_new_path(path):
300 return
301 try:
302 self.set_sensitive(False)
303 try:
304 self.document.save_to_file(path)
305 finally:
306 self.set_sensitive(True)
307 self.set_uri(uri)
308 self.save_done()
309 except:
310 _report_save_error()
311 else:
312 rox.info(_("Drag the icon to a directory viewer\n"
313 "(or enter a full pathname)"))
315 def drag_begin(self, drag_box, context):
316 self.drag_in_progress = 1
317 self.destroy_on_drag_end = 0
318 self.using_xds = 0
319 self.data_sent = 0
321 try:
322 pixbuf = self.icon.get_pixbuf()
323 if pixbuf:
324 drag_box.drag_source_set_icon_pixbuf(pixbuf)
325 except:
326 # This can happen if we set the broken image...
327 import traceback
328 traceback.print_exc()
330 uri = self.entry.get_text()
331 if uri:
332 i = uri.rfind('/')
333 if (i == -1):
334 leaf = uri
335 else:
336 leaf = uri[i + 1:]
337 else:
338 leaf = _('Unnamed')
339 _write_xds_property(context, leaf)
341 def drag_data_get(self, widget, context, selection_data, info, time):
342 if info == TARGET_RAW:
343 try:
344 self.set_sensitive(False)
345 try:
346 self.document.save_to_selection(selection_data)
347 finally:
348 self.set_sensitive(True)
349 except:
350 _report_save_error()
351 _write_xds_property(context, None)
352 return
354 self.data_sent = 1
355 _write_xds_property(context, None)
357 if self.drag_in_progress:
358 self.destroy_on_drag_end = 1
359 else:
360 self.save_done()
361 return
362 elif info != TARGET_XDS:
363 _write_xds_property(context, None)
364 alert("Bad target requested!")
365 return
367 # Using XDS:
369 # Get the path that the destination app wants us to save to.
370 # If it's local, save and return Success
371 # (or Error if save fails)
372 # If it's remote, return Failure (remote may try another method)
373 # If no URI is given, return Error
374 to_send = 'E'
375 uri = _read_xds_property(context, False)
376 if uri:
377 path = get_local_path(escape(uri))
378 if path:
379 if not self.confirm_new_path(path):
380 to_send = 'E'
381 else:
382 try:
383 self.set_sensitive(False)
384 try:
385 self.document.save_to_file(path)
386 finally:
387 self.set_sensitive(True)
388 self.data_sent = True
389 except:
390 _report_save_error()
391 self.data_sent = False
392 if self.data_sent:
393 to_send = 'S'
394 # (else Error)
395 else:
396 to_send = 'F' # Non-local transfer
397 else:
398 alert("Remote application wants to use " +
399 "Direct Save, but I can't read the " +
400 "XdndDirectSave0 (type text/plain) " +
401 "property.")
403 selection_data.set(selection_data.target, 8, to_send)
405 if to_send != 'E':
406 _write_xds_property(context, None)
407 self.set_uri(uri)
408 if self.data_sent:
409 self.save_done()
411 def confirm_new_path(self, path):
412 """User wants to save to this path. If it's different to the original path,
413 check that it doesn't exist and ask for confirmation if it does.
414 If document.save_last_stat is set, compare with os.stat for an existing file
415 and warn about changes.
416 Returns true to go ahead with the save."""
417 if not os.path.exists(path):
418 return True
419 if os.path.isdir(path):
420 rox.alert(_("'%s' already exists as a directory.") % path)
421 return False
422 if path == self.initial_uri:
423 if self.document.save_last_stat is None:
424 return True # OK. Nothing to compare with.
425 last = self.document.save_last_stat
426 stat = os.stat(path)
427 msg = []
428 if stat.st_mode != last.st_mode:
429 msg.append(_("Permissions changed from %o to %o.") % \
430 (last.st_mode, stat.st_mode))
431 if stat.st_size != last.st_size:
432 msg.append(_("Size was %d bytes; now %d bytes.") % \
433 (last.st_size, stat.st_size))
434 if stat.st_mtime != last.st_mtime:
435 msg.append(_("Modification time changed."))
436 if not msg:
437 return True # No change detected
438 return rox.confirm("File '%s' edited by another program since last load/save. "
439 "Really save (discarding other changes)?\n\n%s" %
440 (path, '\n'.join(msg)), g.STOCK_DELETE)
441 return rox.confirm(_("File '%s' already exists -- overwrite it?") % path,
442 g.STOCK_DELETE, _('_Overwrite'))
444 def set_uri(self, uri):
445 """Data is safely saved somewhere. Update the document's URI and save_last_stat (for local saves).
446 URI is not escaped. Internal."""
447 path = get_local_path(escape(uri))
448 if path is not None:
449 self.document.save_last_stat = os.stat(path) # Record for next time
450 self.document.set_uri(path or uri)
452 def drag_end(self, widget, context):
453 self.drag_in_progress = 0
454 if self.destroy_on_drag_end:
455 self.save_done()
457 def save_done(self):
458 self.document.save_done()
460 class SaveBox(g.Dialog):
461 """A SaveBox is a GtkDialog that contains a SaveArea and, optionally, a Discard button.
462 Calls rox.toplevel_(un)ref automatically.
464 save_area = None
466 def __init__(self, document, uri, type = 'text/plain', discard = False, parent = None):
467 """See SaveArea.__init__.
468 parent was added in version 2.0.5. To support older versions, use set_transient_for.
469 If discard is True then an extra discard button is added to the dialog."""
470 g.Dialog.__init__(self, parent = parent)
471 self.set_has_separator(False)
473 self.add_button(g.STOCK_CANCEL, g.RESPONSE_CANCEL)
474 self.add_button(g.STOCK_SAVE, g.RESPONSE_OK)
475 self.set_default_response(g.RESPONSE_OK)
477 if discard:
478 discard_area = g.HButtonBox()
480 def discard_clicked(event):
481 document.discard()
482 self.destroy()
483 button = rox.ButtonMixed(g.STOCK_DELETE, _('_Discard'))
484 discard_area.pack_start(button, False, True, 2)
485 button.connect('clicked', discard_clicked)
486 button.unset_flags(g.CAN_FOCUS)
487 button.set_flags(g.CAN_DEFAULT)
488 self.vbox.pack_end(discard_area, False, True, 0)
489 self.vbox.reorder_child(discard_area, 0)
491 discard_area.show_all()
493 self.set_title(_('Save As:'))
494 self.set_position(g.WIN_POS_MOUSE)
495 self.set_wmclass('savebox', 'Savebox')
496 self.set_border_width(1)
498 # Might as well make use of the new nested scopes ;-)
499 self.set_save_in_progress(0)
500 class BoxedArea(SaveArea):
501 def set_uri(area, uri):
502 SaveArea.set_uri(area, uri)
503 if discard:
504 document.discard()
505 def save_done(area):
506 document.save_done()
507 self.destroy()
509 def set_sensitive(area, sensitive):
510 if self.window:
511 # Might have been destroyed by now...
512 self.set_save_in_progress(not sensitive)
513 SaveArea.set_sensitive(area, sensitive)
514 save_area = BoxedArea(document, uri, type)
515 self.save_area = save_area
517 save_area.show_all()
518 self.build_main_area()
520 i = uri.rfind('/')
521 i = i + 1
522 # Have to do this here, or the selection gets messed up
523 save_area.entry.grab_focus()
524 g.Editable.select_region(save_area.entry, i, -1) # PyGtk bug
525 #save_area.entry.select_region(i, -1)
527 def got_response(widget, response):
528 if self.save_in_progress:
529 try:
530 document.save_cancelled()
531 except:
532 rox.report_exception()
533 return
534 if response == int(g.RESPONSE_CANCEL):
535 self.destroy()
536 elif response == int(g.RESPONSE_OK):
537 self.save_area.save_to_file_in_entry()
538 elif response == int(g.RESPONSE_DELETE_EVENT):
539 pass
540 else:
541 raise Exception('Unknown response!')
542 self.connect('response', got_response)
544 rox.toplevel_ref()
545 self.connect('destroy', lambda w: rox.toplevel_unref())
547 def set_type(self, type, icon = None):
548 """See SaveArea's method of the same name."""
549 self.save_area.set_type(type, icon)
551 def build_main_area(self):
552 """Place self.save_area somewhere in self.vbox. Override this
553 for more complicated layouts."""
554 self.vbox.add(self.save_area)
556 def set_save_in_progress(self, in_progress):
557 """Called when saving starts and ends. Shade/unshade any widgets as
558 required. Make sure you call the default method too!
559 Not called if box is destroyed from a recursive mainloop inside
560 a save_to_* function."""
561 self.set_response_sensitive(g.RESPONSE_OK, not in_progress)
562 self.save_in_progress = in_progress
564 class StringSaver(SaveBox, Saveable):
565 """A very simple SaveBox which saves the string passed to its constructor."""
566 def __init__(self, string, name):
567 """'string' is the string to save. 'name' is the default filename"""
568 SaveBox.__init__(self, self, name, 'text/plain')
569 self.string = string
571 def save_to_stream(self, stream):
572 stream.write(self.string)
574 class SaveFilter(Saveable):
575 """This Saveable runs a process in the background to generate the
576 save data. Any python streams can be used as the input to and
577 output from the process.
579 The output from the subprocess is saved to the output stream (either
580 directly, for fileno() streams, or via another temporary file).
582 If the process returns a non-zero exit status or writes to stderr,
583 the save fails (messages written to stderr are displayed).
586 command = None
587 stdin = None
589 def set_stdin(self, stream):
590 """Use 'stream' as stdin for the process. If stream is not a
591 seekable fileno() stream then it is copied to a temporary file
592 at this point. If None, the child process will get /dev/null on
593 stdin."""
594 if stream is not None:
595 if hasattr(stream, 'fileno') and hasattr(stream, 'seek'):
596 self.stdin = stream
597 else:
598 import tempfile
599 import shutil
600 self.stdin = tempfile.TemporaryFile()
601 shutil.copyfileobj(stream, self.stdin)
602 else:
603 self.stdin = None
605 def save_to_stream(self, stream):
606 from processes import PipeThroughCommand
608 assert not hasattr(self, 'child_run') # No longer supported
610 self.process = PipeThroughCommand(self.command, self.stdin, stream)
611 self.process.wait()
612 self.process = None
614 def save_cancelled(self):
615 """Send SIGTERM to the child processes."""
616 if self.process:
617 self.killed = 1
618 self.process.kill()