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