Document the new parent argument.
[rox-lib/lack.git] / ROX-Lib2 / python / rox / saving.py
blobea3be8788262f615a2c154a0caa4903e7ab730f9
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, parent = None):
500 """See SaveArea.__init__.
501 parent was added in version 2.0.5. To support older versions, use set_transient_for.
502 If discard is True then an extra discard button is added to the dialog."""
503 g.Dialog.__init__(self, parent = parent)
504 self.set_has_separator(False)
506 self.add_button(g.STOCK_CANCEL, g.RESPONSE_CANCEL)
507 self.add_button(g.STOCK_SAVE, g.RESPONSE_OK)
508 self.set_default_response(g.RESPONSE_OK)
510 if discard:
511 discard_area = g.HButtonBox()
513 def discard_clicked(event):
514 document.discard()
515 self.destroy()
516 button = rox.ButtonMixed(g.STOCK_DELETE, _('_Discard'))
517 discard_area.pack_start(button, False, True, 2)
518 button.connect('clicked', discard_clicked)
519 button.unset_flags(g.CAN_FOCUS)
520 button.set_flags(g.CAN_DEFAULT)
521 self.vbox.pack_end(discard_area, False, True, 0)
522 self.vbox.reorder_child(discard_area, 0)
524 discard_area.show_all()
526 self.set_title(_('Save As:'))
527 self.set_position(g.WIN_POS_MOUSE)
528 self.set_wmclass('savebox', 'Savebox')
529 self.set_border_width(1)
531 # Might as well make use of the new nested scopes ;-)
532 self.set_save_in_progress(0)
533 class BoxedArea(SaveArea):
534 def set_uri(area, uri):
535 SaveArea.set_uri(area, uri)
536 if discard:
537 document.discard()
538 def save_done(area):
539 document.save_done()
540 self.destroy()
542 def set_sensitive(area, sensitive):
543 if self.window:
544 # Might have been destroyed by now...
545 self.set_save_in_progress(not sensitive)
546 SaveArea.set_sensitive(area, sensitive)
547 save_area = BoxedArea(document, uri, type)
548 self.save_area = save_area
550 save_area.show_all()
551 self.build_main_area()
553 i = uri.rfind('/')
554 i = i + 1
555 # Have to do this here, or the selection gets messed up
556 save_area.entry.grab_focus()
557 g.Editable.select_region(save_area.entry, i, -1) # PyGtk bug
558 #save_area.entry.select_region(i, -1)
560 def got_response(widget, response):
561 if self.save_in_progress:
562 try:
563 document.save_cancelled()
564 except:
565 rox.report_exception()
566 return
567 if response == int(g.RESPONSE_CANCEL):
568 self.destroy()
569 elif response == int(g.RESPONSE_OK):
570 self.save_area.save_to_file_in_entry()
571 elif response == int(g.RESPONSE_DELETE_EVENT):
572 pass
573 else:
574 raise Exception('Unknown response!')
575 self.connect('response', got_response)
577 rox.toplevel_ref()
578 self.connect('destroy', lambda w: rox.toplevel_unref())
580 def set_type(self, type, icon = None):
581 """See SaveArea's method of the same name."""
582 self.save_area.set_type(type, icon)
584 def build_main_area(self):
585 """Place self.save_area somewhere in self.vbox. Override this
586 for more complicated layouts."""
587 self.vbox.add(self.save_area)
589 def set_save_in_progress(self, in_progress):
590 """Called when saving starts and ends. Shade/unshade any widgets as
591 required. Make sure you call the default method too!
592 Not called if box is destroyed from a recursive mainloop inside
593 a save_to_* function."""
594 self.set_response_sensitive(g.RESPONSE_OK, not in_progress)
595 self.save_in_progress = in_progress
597 class StringSaver(SaveBox, Saveable):
598 """A very simple SaveBox which saves the string passed to its constructor."""
599 def __init__(self, string, name):
600 """'string' is the string to save. 'name' is the default filename"""
601 SaveBox.__init__(self, self, name, 'text/plain')
602 self.string = string
604 def save_to_stream(self, stream):
605 stream.write(self.string)
607 class SaveFilter(Saveable):
608 """This Saveable runs a process in the background to generate the
609 save data. Any python streams can be used as the input to and
610 output from the process.
612 The output from the subprocess is saved to the output stream (either
613 directly, for fileno() streams, or via another temporary file).
615 If the process returns a non-zero exit status or writes to stderr,
616 the save fails (messages written to stderr are displayed).
619 command = None
620 stdin = None
622 def set_stdin(self, stream):
623 """Use 'stream' as stdin for the process. If stream is not a
624 seekable fileno() stream then it is copied to a temporary file
625 at this point. If None, the child process will get /dev/null on
626 stdin."""
627 if stream is not None:
628 if hasattr(stream, 'fileno') and hasattr(stream, 'seek'):
629 self.stdin = stream
630 else:
631 import tempfile
632 import shutil
633 self.stdin = tempfile.TemporaryFile()
634 shutil.copyfileobj(stream, self.stdin)
635 else:
636 self.stdin = None
638 def save_to_stream(self, stream):
639 from processes import PipeThroughCommand
641 assert not hasattr(self, 'child_run') # No longer supported
643 self.process = PipeThroughCommand(self.command, self.stdin, stream)
644 self.process.wait()
645 self.process = None
647 def save_cancelled(self):
648 """Send SIGTERM to the child processes."""
649 if self.process:
650 self.killed = 1
651 self.process.kill()