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
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 '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')
55 path
= choices
.load('MIME-icons',
56 media
+ '_' + subtype
+ '.png')
59 icon_name
= 'mime-%s:%s' % (media
, subtype
)
62 path
=users_theme
.lookup_icon(icon_name
, size
, flags
)
64 icon_name
= 'mime-%s' % media
65 path
= users_theme
.lookup_icon(icon_name
, size
)
68 print "Error loading MIME icon"
71 path
= basedir
.load_first_config('rox.sourceforge.net',
72 'MIME-icons', media
+ '.png')
74 path
= choices
.load('MIME-icons', media
+ '.png')
76 if hasattr(gdk
, 'pixbuf_new_from_file_at_size'):
77 return gdk
.pixbuf_new_from_file_at_size(path
, size
, size
)
79 return gdk
.pixbuf_new_from_file(path
)
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
):
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
96 def __init__(self
, message
):
97 self
.message
= message
98 rox
.UserAbort
.__init
__(self
, message
)
102 rox
.alert(self
.message
)
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 %)."""
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
127 raise Exception('You forgot to write the save_to_stream() method...'
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
141 fileutils
.makedirs(parent_dir
)
143 raise AbortSave(None) # (message already shown)
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')
153 stream
= open_private(tmp
)
155 # Can't create backup... try a direct write
157 stream
= open_private(path
)
160 self
.save_to_stream(stream
)
167 if tmp
and os
.path
.exists(tmp
):
168 if os
.path
.getsize(tmp
) == 0 or \
169 rox
.confirm(_("Delete temporary file '%s'?") % tmp
,
172 raise AbortSave(None)
173 self
.save_set_permissions(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
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
193 save_mode
= self
.save_mode
195 if save_mode
is not None:
196 _chmod(path
, save_mode
)
198 mask
= os
.umask(0077) # Get the current umask
199 os
.umask(mask
) # Set it back how it was
200 _chmod(path
, 0666 & ~mask
)
203 """Time to close the savebox. Default method does nothing."""
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.
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
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
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
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)
265 entry
.connect('activate', lambda w
: self
.save_to_file_in_entry())
267 self
.pack_start(entry
, False, True, 4)
272 def _set_icon(self
, type):
273 pixbuf
= image_for_type(type)
275 self
.icon
.set_from_pixbuf(pixbuf
)
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()
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
)
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."""
306 self
.icon
.set_from_pixbuf(icon
.get_pixbuf())
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
)]
316 if self
.document
.can_save_to_selection():
317 targets
= targets
+ [(type, 0, TARGET_RAW
),
318 ('application/octet-stream', 0, TARGET_RAW
)]
321 raise Exception("Document %s can't save!" % self
.document
)
322 self
.drag_box
.drag_source_set(gdk
.BUTTON1_MASK | gdk
.BUTTON3_MASK
,
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
))
332 if not self
.confirm_new_path(path
):
335 self
.set_sensitive(False)
337 self
.document
.save_to_file(path
)
339 self
.set_sensitive(True)
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
355 pixbuf
= self
.icon
.get_pixbuf()
357 drag_box
.drag_source_set_icon_pixbuf(pixbuf
)
359 # This can happen if we set the broken image...
361 traceback
.print_exc()
363 uri
= self
.entry
.get_text()
372 _write_xds_property(context
, leaf
)
374 def drag_data_get(self
, widget
, context
, selection_data
, info
, time
):
375 if info
== TARGET_RAW
:
377 self
.set_sensitive(False)
379 self
.document
.save_to_selection(selection_data
)
381 self
.set_sensitive(True)
384 _write_xds_property(context
, None)
388 _write_xds_property(context
, None)
390 if self
.drag_in_progress
:
391 self
.destroy_on_drag_end
= 1
395 elif info
!= TARGET_XDS
:
396 _write_xds_property(context
, None)
397 alert("Bad target requested!")
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
408 uri
= _read_xds_property(context
, False)
410 path
= get_local_path(escape(uri
))
412 if not self
.confirm_new_path(path
):
416 self
.set_sensitive(False)
418 self
.document
.save_to_file(path
)
420 self
.set_sensitive(True)
421 self
.data_sent
= True
424 self
.data_sent
= False
429 to_send
= 'F' # Non-local transfer
431 alert("Remote application wants to use " +
432 "Direct Save, but I can't read the " +
433 "XdndDirectSave0 (type text/plain) " +
436 selection_data
.set(selection_data
.target
, 8, to_send
)
439 _write_xds_property(context
, None)
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
):
452 if os
.path
.isdir(path
):
453 rox
.alert(_("'%s' already exists as a directory.") % path
)
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
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."))
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
))
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
:
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.
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
)
510 discard_area
= g
.HButtonBox()
512 def discard_clicked(event
):
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
)
541 def set_sensitive(area
, sensitive
):
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
550 self
.build_main_area()
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
:
562 document
.save_cancelled()
564 rox
.report_exception()
566 if response
== int(g
.RESPONSE_CANCEL
):
568 elif response
== int(g
.RESPONSE_OK
):
569 self
.save_area
.save_to_file_in_entry()
570 elif response
== int(g
.RESPONSE_DELETE_EVENT
):
573 raise Exception('Unknown response!')
574 self
.connect('response', got_response
)
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')
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).
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
626 if stream
is not None:
627 if hasattr(stream
, 'fileno') and hasattr(stream
, 'seek'):
632 self
.stdin
= tempfile
.TemporaryFile()
633 shutil
.copyfileobj(stream
, self
.stdin
)
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
)
646 def save_cancelled(self
):
647 """Send SIGTERM to the child processes."""