Minor updates and a workaround for another pygtk bug (probably fixed now).
[rox-lib.git] / python / rox / saving.py
blob93583d90442b83f10e613908ac28559e5551a1ad
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
11 from rox import choices, get_local_path, TRUE, FALSE
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 media, subtype = type.split('/', 1)
37 path = choices.load('MIME-icons', media + '_' + subtype + '.png')
38 if not path:
39 path = choices.load('MIME-icons', media + '.png')
40 if path:
41 return gdk.pixbuf_new_from_file(path)
42 else:
43 return None
45 def _report_save_error():
46 "Report a SaveAbort nicely, otherwise use report_exception()"
47 type, value = sys.exc_info()[:2]
48 if isinstance(value, AbortSave):
49 value.show()
50 else:
51 rox.report_exception()
53 class AbortSave(Exception):
54 """Raise this to cancel a save. If a message is given, it is displayed
55 in a normal alert box (not in the report_exception style). If the
56 message is None, no message is shown (you should have already shown
57 it!)"""
58 def __init__(self, message):
59 self.message = message
61 def show(self):
62 if self.message:
63 rox.alert(self.message)
65 class Saveable:
66 """This class describes the interface that an object must provide
67 to work with the SaveBox/SaveArea widgets. Inherit from it if you
68 want to save. All methods can be overridden, but normally only
69 save_to_stream() needs to be."""
71 def set_uri(self, uri):
72 """When the data is safely saved somewhere this is called
73 with its new name. Mark your data as unmodified and update
74 the filename for next time. Saving to another application
75 won't call this method. Default method does nothing."""
76 pass
78 def save_to_stream(self, stream):
79 """Write the data to save to the stream. When saving to a
80 local file, stream will be the actual file, otherwise it is a
81 cStringIO object."""
82 raise Exception('You forgot to write the save_to_stream() method...'
83 'silly programmer!')
85 def save_to_file(self, path):
86 """Write data to file. Raise an exception on error.
87 The default creates a temporary file, uses save_to_stream() to
88 write to it, then renames it over the original. If the temporary file
89 can't be created, it writes directly over the original."""
90 import random
91 tmp = 'tmp-' + `random.randrange(1000000)`
92 tmp = os.path.join(os.path.dirname(path), tmp)
94 def open(path):
95 return os.fdopen(os.open(path, os.O_CREAT | os.O_WRONLY, 0600), 'wb')
97 try:
98 file = open(tmp)
99 except:
100 # Can't create backup... try a direct write
101 tmp = None
102 file = open(path)
103 try:
104 try:
105 self.save_to_stream(file)
106 finally:
107 file.close()
108 if tmp:
109 os.rename(tmp, path)
110 except:
111 _report_save_error()
112 if tmp and os.path.exists(tmp):
113 if os.path.getsize(tmp) == 0 or \
114 rox.confirm(_("Delete temporary file '%s'?") % tmp,
115 g.STOCK_DELETE):
116 os.unlink(tmp)
117 raise AbortSave(None)
118 self.save_set_permissions(path)
119 filer.examine(path)
121 def save_to_selection(self, selection_data):
122 """Write data to the selection. The default method uses save_to_stream()."""
123 from cStringIO import StringIO
124 stream = StringIO()
125 self.save_to_stream(stream)
126 selection_data.set(selection_data.target, 8, stream.getvalue())
128 save_mode = None
129 def save_set_permissions(self, path):
130 """The default save_to_file() creates files with the mode 0600
131 (user read/write only). After saving has finished, it calls this
132 method to set the final permissions. The save_set_permissions():
133 - sets it to 0666 masked with the umask (if save_mode is None), or
134 - sets it to save_mode (not masked) otherwise."""
135 if self.save_mode is not None:
136 os.chmod(path, self.save_mode)
137 else:
138 mask = os.umask(0077) # Get the current umask
139 os.umask(mask) # Set it back how it was
140 os.chmod(path, 0666 & ~mask)
142 def save_done(self):
143 """Time to close the savebox. Default method does nothing."""
144 pass
146 def discard(self):
147 """Discard button clicked, or document safely saved. Only called if a SaveBox
148 was created with discard=1.
149 The user doesn't want the document any more, even if it's modified and unsaved.
150 Delete it."""
151 raise Exception("Sorry... my programmer forgot to tell me how to handle Discard!")
153 save_to_stream._rox_default = 1
154 save_to_file._rox_default = 1
155 save_to_selection._rox_default = 1
156 def can_save_to_file(self):
157 """Indicates whether we have a working save_to_stream or save_to_file
158 method (ie, whether we can save to files). Default method checks that
159 one of these two methods has been overridden."""
160 if not hasattr(self.save_to_stream, '_rox_default'):
161 return 1 # Have user-provided save_to_stream
162 if not hasattr(self.save_to_file, '_rox_default'):
163 return 1 # Have user-provided save_to_file
164 return 0
165 def can_save_to_selection(self):
166 """Indicates whether we have a working save_to_stream or save_to_selection
167 method (ie, whether we can save to selections). Default methods checks that
168 one of these two methods has been overridden."""
169 if not hasattr(self.save_to_stream, '_rox_default'):
170 return 1 # Have user-provided save_to_stream
171 if not hasattr(self.save_to_selection, '_rox_default'):
172 return 1 # Have user-provided save_to_file
173 return 0
175 def save_cancelled(self):
176 """If you multitask during a save (using a recursive mainloop) then the
177 user may click on the Cancel button. This function gets called if so, and
178 should cause the recursive mainloop to return."""
179 raise Exception("Lazy programmer error: can't abort save!")
181 class SaveArea(g.VBox):
182 """A SaveArea contains the widgets used in a save box. You can use
183 this to put a savebox area in a larger window."""
184 def __init__(self, document, uri, type):
185 """'document' must be a subclass of Saveable.
186 'uri' is the file's current location, or a simple name (eg 'TextFile')
187 if it has never been saved.
188 'type' is the MIME-type to use (eg 'text/plain').
190 g.VBox.__init__(self, FALSE, 0)
192 self.document = document
193 self.initial_uri = uri
195 drag_area = self._create_drag_area(type)
196 self.pack_start(drag_area, TRUE, TRUE, 0)
197 drag_area.show_all()
199 entry = g.Entry()
200 entry.connect('activate', lambda w: self.save_to_file_in_entry())
201 self.entry = entry
202 self.pack_start(entry, FALSE, TRUE, 4)
203 entry.show()
205 entry.set_text(uri)
207 def _set_icon(self, type):
208 pixbuf = image_for_type(type)
209 if pixbuf:
210 self.icon.set_from_pixbuf(pixbuf)
211 else:
212 self.icon.set_from_stock(g.STOCK_MISSING_IMAGE, g.ICON_SIZE_DND)
214 def _create_drag_area(self, type):
215 align = g.Alignment()
216 align.set(.5, .5, 0, 0)
218 self.drag_box = g.EventBox()
219 self.drag_box.set_border_width(4)
220 self.drag_box.add_events(gdk.BUTTON_PRESS_MASK)
221 align.add(self.drag_box)
223 self.icon = g.Image()
224 self._set_icon(type)
226 self._set_drag_source(type)
227 self.drag_box.connect('drag_begin', self.drag_begin)
228 self.drag_box.connect('drag_end', self.drag_end)
229 self.drag_box.connect('drag_data_get', self.drag_data_get)
230 self.drag_in_progress = 0
232 self.drag_box.add(self.icon)
234 return align
236 def set_type(self, type, icon = None):
237 """Change the icon and drag target to 'type'.
238 If 'icon' is given (as a GtkImage) then that icon is used,
239 otherwise an appropriate icon for the type is used."""
240 if icon:
241 self.icon.set_from_pixbuf(icon.get_pixbuf())
242 else:
243 self._set_icon(type)
244 self._set_drag_source(type)
246 def _set_drag_source(self, type):
247 if self.document.can_save_to_file():
248 targets = [('XdndDirectSave0', 0, TARGET_XDS)]
249 else:
250 targets = []
251 if self.document.can_save_to_selection():
252 targets = targets + [(type, 0, TARGET_RAW),
253 ('application/octet-stream', 0, TARGET_RAW)]
255 if not targets:
256 raise Exception("Document %s can't save!" % self.document)
257 self.drag_box.drag_source_set(gdk.BUTTON1_MASK | gdk.BUTTON3_MASK,
258 targets,
259 gdk.ACTION_COPY | gdk.ACTION_MOVE)
261 def save_to_file_in_entry(self):
262 """Call this when the user clicks on an OK button you provide."""
263 uri = self.entry.get_text()
264 path = get_local_path(uri)
266 if path:
267 if not self.confirm_new_path(path):
268 return
269 try:
270 self.set_sensitive(FALSE)
271 try:
272 self.document.save_to_file(path)
273 finally:
274 self.set_sensitive(TRUE)
275 self.set_uri(path)
276 self.save_done()
277 except:
278 _report_save_error()
279 else:
280 rox.info(_("Drag the icon to a directory viewer\n"
281 "(or enter a full pathname)"))
283 def drag_begin(self, drag_box, context):
284 self.drag_in_progress = 1
285 self.destroy_on_drag_end = 0
286 self.using_xds = 0
287 self.data_sent = 0
289 pixbuf = self.icon.get_pixbuf()
290 if pixbuf:
291 drag_box.drag_source_set_icon_pixbuf(pixbuf)
293 uri = self.entry.get_text()
294 if uri:
295 i = uri.rfind('/')
296 if (i == -1):
297 leaf = uri
298 else:
299 leaf = uri[i + 1:]
300 else:
301 leaf = _('Unnamed')
302 _write_xds_property(context, leaf)
304 def drag_data_get(self, widget, context, selection_data, info, time):
305 if info == TARGET_RAW:
306 try:
307 self.set_sensitive(FALSE)
308 try:
309 self.document.save_to_selection(selection_data)
310 finally:
311 self.set_sensitive(TRUE)
312 except:
313 _report_save_error()
314 _write_xds_property(context, None)
315 return
317 self.data_sent = 1
318 _write_xds_property(context, None)
320 if self.drag_in_progress:
321 self.destroy_on_drag_end = 1
322 else:
323 self.save_done()
324 return
325 elif info != TARGET_XDS:
326 _write_xds_property(context, None)
327 alert("Bad target requested!")
328 return
330 # Using XDS:
332 # Get the path that the destination app wants us to save to.
333 # If it's local, save and return Success
334 # (or Error if save fails)
335 # If it's remote, return Failure (remote may try another method)
336 # If no URI is given, return Error
337 to_send = 'E'
338 uri = _read_xds_property(context, FALSE)
339 if uri:
340 path = get_local_path(uri)
341 if path:
342 if not self.confirm_new_path(path):
343 to_send = 'E'
344 else:
345 try:
346 self.set_sensitive(FALSE)
347 try:
348 self.document.save_to_file(path)
349 finally:
350 self.set_sensitive(TRUE)
351 self.data_sent = TRUE
352 except:
353 _report_save_error()
354 self.data_sent = FALSE
355 if self.data_sent:
356 to_send = 'S'
357 # (else Error)
358 else:
359 to_send = 'F' # Non-local transfer
360 else:
361 alert("Remote application wants to use " +
362 "Direct Save, but I can't read the " +
363 "XdndDirectSave0 (type text/plain) " +
364 "property.")
366 selection_data.set(selection_data.target, 8, to_send)
368 if to_send != 'E':
369 _write_xds_property(context, None)
370 path = get_local_path(uri)
371 if path:
372 self.set_uri(path)
373 else:
374 self.set_uri(uri)
375 if self.data_sent:
376 self.save_done()
378 def confirm_new_path(self, path):
379 """Use wants to save to this path. If it's different to the original path,
380 check that it doesn't exist and ask for confirmation if it does. Returns true
381 to go ahead with the save."""
382 if path == self.initial_uri:
383 return 1
384 if not os.path.exists(path):
385 return 1
386 return rox.confirm(_("File '%s' already exists -- overwrite it?") % path,
387 g.STOCK_DELETE, _('_Overwrite'))
389 def set_uri(self, uri):
390 "Data is safely saved somewhere. Update the document's URI. Internal."
391 self.document.set_uri(uri)
393 def drag_end(self, widget, context):
394 self.drag_in_progress = 0
395 if self.destroy_on_drag_end:
396 self.save_done()
398 def save_done(self):
399 self.document.save_done()
401 class SaveBox(g.Dialog):
402 """A SaveBox is a GtkDialog that contains a SaveArea and, optionally, a Discard button.
403 Calls rox.toplevel_(un)ref automatically.
406 def __init__(self, document, uri, type = 'text/plain', discard = FALSE):
407 """See SaveArea.__init__.
408 If discard is TRUE then an extra discard button is added to the dialog."""
409 g.Dialog.__init__(self)
410 self.set_has_separator(FALSE)
412 self.add_button(g.STOCK_CANCEL, g.RESPONSE_CANCEL)
413 self.add_button(g.STOCK_SAVE, g.RESPONSE_OK)
414 self.set_default_response(g.RESPONSE_OK)
416 if discard:
417 discard_area = g.HButtonBox()
419 def discard_clicked(event):
420 document.discard()
421 self.destroy()
422 button = rox.ButtonMixed(g.STOCK_DELETE, _('_Discard'))
423 discard_area.pack_start(button, FALSE, TRUE, 2)
424 button.connect('clicked', discard_clicked)
425 button.unset_flags(g.CAN_FOCUS)
426 button.set_flags(g.CAN_DEFAULT)
427 self.vbox.pack_end(discard_area, FALSE, TRUE, 0)
428 self.vbox.reorder_child(discard_area, 0)
430 discard_area.show_all()
432 self.set_title(_('Save As:'))
433 self.set_position(g.WIN_POS_MOUSE)
434 self.set_wmclass('savebox', 'Savebox')
435 self.set_border_width(1)
437 # Might as well make use of the new nested scopes ;-)
438 self.set_save_in_progress(0)
439 class BoxedArea(SaveArea):
440 def set_uri(area, uri):
441 document.set_uri(uri)
442 if discard:
443 document.discard()
444 def save_done(area):
445 document.save_done()
446 self.destroy()
448 def set_sensitive(area, sensitive):
449 if self.window:
450 # Might have been destroyed by now...
451 self.set_save_in_progress(not sensitive)
452 SaveArea.set_sensitive(area, sensitive)
453 save_area = BoxedArea(document, uri, type)
454 self.save_area = save_area
456 save_area.show_all()
457 self.build_main_area()
459 i = uri.rfind('/')
460 i = i + 1
461 # Have to do this here, or the selection gets messed up
462 save_area.entry.grab_focus()
463 g.Editable.select_region(save_area.entry, i, -1) # PyGtk bug
464 #save_area.entry.select_region(i, -1)
466 def got_response(widget, response):
467 if self.save_in_progress:
468 try:
469 document.save_cancelled()
470 except:
471 rox.report_exception()
472 return
473 if response == g.RESPONSE_CANCEL:
474 self.destroy()
475 elif response == g.RESPONSE_OK:
476 self.save_area.save_to_file_in_entry()
477 elif response == g.RESPONSE_DELETE_EVENT:
478 pass
479 else:
480 raise Exception('Unknown response!')
481 self.connect('response', got_response)
483 rox.toplevel_ref()
484 self.connect('destroy', lambda w: rox.toplevel_unref())
486 def set_type(self, type, icon = None):
487 """See SaveArea's method of the same name."""
488 self.save_area.set_type(type, icon)
490 def build_main_area(self):
491 """Place self.save_area somewhere in self.vbox. Override this
492 for more complicated layouts."""
493 self.vbox.add(self.save_area)
495 def set_save_in_progress(self, in_progress):
496 """Called when saving starts and ends. Shade/unshade any widgets as
497 required. Make sure you call the default method too!
498 Not called if box is destroyed from a recursive mainloop inside
499 a save_to_* function."""
500 self.set_response_sensitive(g.RESPONSE_OK, not in_progress)
501 self.save_in_progress = in_progress
503 class StringSaver(SaveBox, Saveable):
504 """A very simple SaveBox which saves the string passed to its constructor."""
505 def __init__(self, string, name):
506 """'string' is the string to save. 'name' is the default filename"""
507 SaveBox.__init__(self, self, name, 'text/plain')
508 self.string = string
510 def save_to_stream(self, stream):
511 stream.write(self.string)
513 class SaveFilter(Saveable):
514 """This Saveable runs a process in the background to generate the
515 save data. Any python streams can be used as the input to and
516 output from the process.
518 The output from the subprocess is saved to the output stream (either
519 directly, for fileno() streams, or via another temporary file).
521 If the process returns a non-zero exit status or writes to stderr,
522 the save fails (messages written to stderr are displayed).
525 stdin = None
527 def set_stdin(self, stream):
528 """Use 'stream' as stdin for the process. If stream is not a
529 seekable fileno() stream then it is copied to a temporary file
530 at this point. If None, the child process will get /dev/null on
531 stdin."""
532 if stream is not None:
533 if hasattr(stream, 'fileno') and hasattr(stream, 'seek'):
534 self.stdin = stream
535 else:
536 import tempfile
537 import shutil
538 self.stdin = tempfile.TemporaryFile()
539 shutil.copyfileobj(stream, self.stdin)
540 else:
541 self.stdin = None
543 def save_to_stream(self, stream):
544 from processes import Process
545 from cStringIO import StringIO
546 errors = StringIO()
547 done = []
549 # Get the FD for the output, creating a tmp file if needed
550 if hasattr(stream, 'fileno'):
551 stdout_fileno = stream.fileno()
552 tmp = None
553 else:
554 import tempfile
555 tmp = tempfile.TemporaryFile()
556 stdout_fileno = tmp.fileno()
558 # Get the FD for the input
559 if self.stdin:
560 stdin_fileno = self.stdin.fileno()
561 self.stdin.seek(0)
562 else:
563 stdin_fileno = os.open('/dev/null', os.O_RDONLY)
565 class FilterProcess(Process):
566 def child_post_fork(self):
567 if stdout_fileno != 1:
568 os.dup2(stdout_fileno, 1)
569 os.close(stdout_fileno)
570 if stdin_fileno is not None and stdin_fileno != 0:
571 os.dup2(stdin_fileno, 0)
572 os.close(stdin_fileno)
573 def got_error_output(self, data):
574 errors.write(data)
575 def child_died(self, status):
576 done.append(status)
577 g.mainquit()
578 def child_run(proc):
579 self.child_run()
580 self.process = FilterProcess()
581 self.killed = 0
582 self.process.start()
583 while not done:
584 g.mainloop()
585 self.process = None
586 status = done[0]
587 if self.killed:
588 print >> errors, '\nProcess terminated at user request'
589 error = errors.getvalue().strip()
590 if error:
591 raise AbortSave(error)
592 if status:
593 raise AbortSave('child_run() returned an error code, but no error message!')
594 if tmp:
595 # Data went to a temp file
596 tmp.seek(0)
597 stream.write(tmp.read())
599 def child_run(self):
600 """This is run in the child process. The default method runs 'self.command'
601 using os.system() and prints a message to stderr if the exit code is non-zero.
602 DO NOT call gtk functions here!
604 Be careful to escape shell special characters when inserting filenames!
606 command = self.command
607 if os.system(command):
608 print >>sys.stderr, "Command:\n%s\nreturned an error code" % command
609 os._exit(0) # Writing to stderr indicates error...
611 def save_cancelled(self):
612 """Send SIGTERM to the child processes."""
613 if self.process:
614 self.killed = 1
615 self.process.kill()