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, 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
)
511 discard_area
= g
.HButtonBox()
513 def discard_clicked(event
):
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
)
542 def set_sensitive(area
, sensitive
):
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
551 self
.build_main_area()
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
:
563 document
.save_cancelled()
565 rox
.report_exception()
567 if response
== int(g
.RESPONSE_CANCEL
):
569 elif response
== int(g
.RESPONSE_OK
):
570 self
.save_area
.save_to_file_in_entry()
571 elif response
== int(g
.RESPONSE_DELETE_EVENT
):
574 raise Exception('Unknown response!')
575 self
.connect('response', got_response
)
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')
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).
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
627 if stream
is not None:
628 if hasattr(stream
, 'fileno') and hasattr(stream
, 'seek'):
633 self
.stdin
= tempfile
.TemporaryFile()
634 shutil
.copyfileobj(stream
, self
.stdin
)
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
)
647 def save_cancelled(self
):
648 """Send SIGTERM to the child processes."""