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."""
10 from rox
import alert
, g
, _
, filer
, escape
11 from rox
import choices
, get_local_path
, basedir
, mime
18 def _chmod(path
, mode
):
19 """Like os.chmod, except that permission denied errors are not fatal
20 (for FAT partitions)."""
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
32 win
.property_change('XdndDirectSave0', 'text/plain', 8,
33 gdk
.PROP_MODE_REPLACE
,
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
)
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
):
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
63 def __init__(self
, message
):
64 self
.message
= message
65 rox
.UserAbort
.__init
__(self
, message
)
69 rox
.alert(self
.message
)
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."""
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 %)."""
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
94 raise Exception('You forgot to write the save_to_stream() method...'
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
108 fileutils
.makedirs(parent_dir
)
110 raise AbortSave(None) # (message already shown)
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')
120 stream
= open_private(tmp
)
122 # Can't create backup... try a direct write
124 stream
= open_private(path
)
127 self
.save_to_stream(stream
)
134 if tmp
and os
.path
.exists(tmp
):
135 if os
.path
.getsize(tmp
) == 0 or \
136 rox
.confirm(_("Delete temporary file '%s'?") % tmp
,
139 raise AbortSave(None)
140 self
.save_set_permissions(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
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
160 save_mode
= self
.save_mode
162 if save_mode
is not None:
163 _chmod(path
, save_mode
)
165 mask
= os
.umask(0077) # Get the current umask
166 os
.umask(mask
) # Set it back how it was
167 _chmod(path
, 0666 & ~mask
)
170 """Time to close the savebox. Default method does nothing."""
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.
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
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
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
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)
232 entry
.connect('activate', lambda w
: self
.save_to_file_in_entry())
234 self
.pack_start(entry
, False, True, 4)
239 def _set_icon(self
, type):
240 pixbuf
= mime
.image_for_type(type)
242 self
.icon
.set_from_pixbuf(pixbuf
)
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()
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
)
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."""
273 self
.icon
.set_from_pixbuf(icon
.get_pixbuf())
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
)]
283 if self
.document
.can_save_to_selection():
284 targets
= targets
+ [(type, 0, TARGET_RAW
),
285 ('application/octet-stream', 0, TARGET_RAW
)]
288 raise Exception("Document %s can't save!" % self
.document
)
289 self
.drag_box
.drag_source_set(gdk
.BUTTON1_MASK | gdk
.BUTTON3_MASK
,
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
))
299 if not self
.confirm_new_path(path
):
302 self
.set_sensitive(False)
304 self
.document
.save_to_file(path
)
306 self
.set_sensitive(True)
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
322 pixbuf
= self
.icon
.get_pixbuf()
324 drag_box
.drag_source_set_icon_pixbuf(pixbuf
)
326 # This can happen if we set the broken image...
328 traceback
.print_exc()
330 uri
= self
.entry
.get_text()
339 _write_xds_property(context
, leaf
)
341 def drag_data_get(self
, widget
, context
, selection_data
, info
, time
):
342 if info
== TARGET_RAW
:
344 self
.set_sensitive(False)
346 self
.document
.save_to_selection(selection_data
)
348 self
.set_sensitive(True)
351 _write_xds_property(context
, None)
355 _write_xds_property(context
, None)
357 if self
.drag_in_progress
:
358 self
.destroy_on_drag_end
= 1
362 elif info
!= TARGET_XDS
:
363 _write_xds_property(context
, None)
364 alert("Bad target requested!")
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
375 uri
= _read_xds_property(context
, False)
377 path
= get_local_path(escape(uri
))
379 if not self
.confirm_new_path(path
):
383 self
.set_sensitive(False)
385 self
.document
.save_to_file(path
)
387 self
.set_sensitive(True)
388 self
.data_sent
= True
391 self
.data_sent
= False
396 to_send
= 'F' # Non-local transfer
398 alert("Remote application wants to use " +
399 "Direct Save, but I can't read the " +
400 "XdndDirectSave0 (type text/plain) " +
403 selection_data
.set(selection_data
.target
, 8, to_send
)
406 _write_xds_property(context
, None)
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
):
419 if os
.path
.isdir(path
):
420 rox
.alert(_("'%s' already exists as a directory.") % path
)
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
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."))
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
))
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
:
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.
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
)
478 discard_area
= g
.HButtonBox()
480 def discard_clicked(event
):
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
)
509 def set_sensitive(area
, sensitive
):
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
518 self
.build_main_area()
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
:
530 document
.save_cancelled()
532 rox
.report_exception()
534 if response
== int(g
.RESPONSE_CANCEL
):
536 elif response
== int(g
.RESPONSE_OK
):
537 self
.save_area
.save_to_file_in_entry()
538 elif response
== int(g
.RESPONSE_DELETE_EVENT
):
541 raise Exception('Unknown response!')
542 self
.connect('response', got_response
)
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')
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).
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
594 if stream
is not None:
595 if hasattr(stream
, 'fileno') and hasattr(stream
, 'seek'):
600 self
.stdin
= tempfile
.TemporaryFile()
601 shutil
.copyfileobj(stream
, self
.stdin
)
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
)
614 def save_cancelled(self
):
615 """Send SIGTERM to the child processes."""