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