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
, info
, g
, _
, filer
, escape
11 from rox
import choices
, get_local_path
18 def _write_xds_property(context
, value
):
19 win
= context
.source_window
21 win
.property_change('XdndDirectSave0', 'text/plain', 8,
22 gdk
.PROP_MODE_REPLACE
,
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
)
34 def image_for_type(type):
35 'Search <Choices> for a suitable icon. Returns a pixbuf, or None.'
36 from icon_theme
import rox_theme
37 media
, subtype
= type.split('/', 1)
38 path
= choices
.load('MIME-icons', media
+ '_' + subtype
+ '.png')
40 icon
= 'mime-%s:%s' % (media
, subtype
)
42 path
= rox_theme
.lookup_icon(icon
, 48)
44 icon
= 'mime-%s' % media
45 path
= rox_theme
.lookup_icon(icon
, 48)
47 print "Error loading MIME icon"
49 path
= choices
.load('MIME-icons', media
+ '.png')
51 return gdk
.pixbuf_new_from_file(path
)
55 def _report_save_error():
56 "Report a AbortSave nicely, otherwise use report_exception()"
57 value
= sys
.exc_info()[1]
58 if isinstance(value
, AbortSave
):
61 rox
.report_exception()
63 class AbortSave(rox
.UserAbort
):
64 """Raise this to cancel a save. If a message is given, it is displayed
65 in a normal alert box (not in the report_exception style). If the
66 message is None, no message is shown (you should have already shown
68 def __init__(self
, message
):
69 self
.message
= message
70 rox
.UserAbort(self
, message
)
74 rox
.alert(self
.message
)
77 """This class describes the interface that an object must provide
78 to work with the SaveBox/SaveArea widgets. Inherit from it if you
79 want to save. All methods can be overridden, but normally only
80 save_to_stream() needs to be. You can also set save_last_stat to
81 the result of os.stat(filename) when loading a file to make ROX-Lib
82 restore permissions and warn about other programs editing the file."""
86 def set_uri(self
, uri
):
87 """When the data is safely saved somewhere this is called
88 with its new name. Mark your data as unmodified and update
89 the filename for next time. Saving to another application
90 won't call this method. Default method does nothing."""
93 def save_to_stream(self
, stream
):
94 """Write the data to save to the stream. When saving to a
95 local file, stream will be the actual file, otherwise it is a
97 raise Exception('You forgot to write the save_to_stream() method...'
100 def save_to_file(self
, path
):
101 """Write data to file. Raise an exception on error.
102 The default creates a temporary file, uses save_to_stream() to
103 write to it, then renames it over the original. If the temporary file
104 can't be created, it writes directly over the original."""
106 # Ensure the directory exists...
107 parent_dir
= os
.path
.dirname(path
)
108 if not os
.path
.isdir(parent_dir
):
109 from rox
import fileutils
111 fileutils
.makedirs(parent_dir
)
113 raise AbortSave(None) # (message already shown)
116 tmp
= 'tmp-' + `random
.randrange(1000000)`
117 tmp
= os
.path
.join(parent_dir
, tmp
)
119 def open_private(path
):
120 return os
.fdopen(os
.open(path
, os
.O_CREAT | os
.O_WRONLY
, 0600), 'wb')
123 stream
= open_private(tmp
)
125 # Can't create backup... try a direct write
127 stream
= open_private(path
)
130 self
.save_to_stream(stream
)
137 if tmp
and os
.path
.exists(tmp
):
138 if os
.path
.getsize(tmp
) == 0 or \
139 rox
.confirm(_("Delete temporary file '%s'?") % tmp
,
142 raise AbortSave(None)
143 self
.save_set_permissions(path
)
146 def save_to_selection(self
, selection_data
):
147 """Write data to the selection. The default method uses save_to_stream()."""
148 from cStringIO
import StringIO
150 self
.save_to_stream(stream
)
151 selection_data
.set(selection_data
.target
, 8, stream
.getvalue())
153 save_mode
= None # For backwards compat
154 def save_set_permissions(self
, path
):
155 """The default save_to_file() creates files with the mode 0600
156 (user read/write only). After saving has finished, it calls this
157 method to set the final permissions. The save_set_permissions():
158 - sets it to 0666 masked with the umask (if save_mode is None), or
159 - sets it to save_last_stat.st_mode (not masked) otherwise."""
160 if self
.save_last_stat
is not None:
161 save_mode
= self
.save_last_stat
.st_mode
163 save_mode
= self
.save_mode
165 if save_mode
is not None:
166 os
.chmod(path
, save_mode
)
168 mask
= os
.umask(0077) # Get the current umask
169 os
.umask(mask
) # Set it back how it was
170 os
.chmod(path
, 0666 & ~mask
)
173 """Time to close the savebox. Default method does nothing."""
177 """Discard button clicked, or document safely saved. Only called if a SaveBox
178 was created with discard=1.
179 The user doesn't want the document any more, even if it's modified and unsaved.
181 raise Exception("Sorry... my programmer forgot to tell me how to handle Discard!")
183 save_to_stream
._rox
_default
= 1
184 save_to_file
._rox
_default
= 1
185 save_to_selection
._rox
_default
= 1
186 def can_save_to_file(self
):
187 """Indicates whether we have a working save_to_stream or save_to_file
188 method (ie, whether we can save to files). Default method checks that
189 one of these two methods has been overridden."""
190 if not hasattr(self
.save_to_stream
, '_rox_default'):
191 return 1 # Have user-provided save_to_stream
192 if not hasattr(self
.save_to_file
, '_rox_default'):
193 return 1 # Have user-provided save_to_file
195 def can_save_to_selection(self
):
196 """Indicates whether we have a working save_to_stream or save_to_selection
197 method (ie, whether we can save to selections). Default methods checks that
198 one of these two methods has been overridden."""
199 if not hasattr(self
.save_to_stream
, '_rox_default'):
200 return 1 # Have user-provided save_to_stream
201 if not hasattr(self
.save_to_selection
, '_rox_default'):
202 return 1 # Have user-provided save_to_file
205 def save_cancelled(self
):
206 """If you multitask during a save (using a recursive mainloop) then the
207 user may click on the Cancel button. This function gets called if so, and
208 should cause the recursive mainloop to return."""
209 raise Exception("Lazy programmer error: can't abort save!")
211 class SaveArea(g
.VBox
):
212 """A SaveArea contains the widgets used in a save box. You can use
213 this to put a savebox area in a larger window."""
215 document
= None # The Saveable with the data
217 initial_uri
= None # The pathname supplied to the constructor
219 def __init__(self
, document
, uri
, type):
220 """'document' must be a subclass of Saveable.
221 'uri' is the file's current location, or a simple name (eg 'TextFile')
222 if it has never been saved.
223 'type' is the MIME-type to use (eg 'text/plain').
225 g
.VBox
.__init
__(self
, False, 0)
227 self
.document
= document
228 self
.initial_uri
= uri
230 drag_area
= self
._create
_drag
_area
(type)
231 self
.pack_start(drag_area
, True, True, 0)
235 entry
.connect('activate', lambda w
: self
.save_to_file_in_entry())
237 self
.pack_start(entry
, False, True, 4)
242 def _set_icon(self
, type):
243 pixbuf
= image_for_type(type)
245 self
.icon
.set_from_pixbuf(pixbuf
)
247 self
.icon
.set_from_stock(g
.STOCK_MISSING_IMAGE
, g
.ICON_SIZE_DND
)
249 def _create_drag_area(self
, type):
250 align
= g
.Alignment()
251 align
.set(.5, .5, 0, 0)
253 self
.drag_box
= g
.EventBox()
254 self
.drag_box
.set_border_width(4)
255 self
.drag_box
.add_events(gdk
.BUTTON_PRESS_MASK
)
256 align
.add(self
.drag_box
)
258 self
.icon
= g
.Image()
261 self
._set
_drag
_source
(type)
262 self
.drag_box
.connect('drag_begin', self
.drag_begin
)
263 self
.drag_box
.connect('drag_end', self
.drag_end
)
264 self
.drag_box
.connect('drag_data_get', self
.drag_data_get
)
265 self
.drag_in_progress
= 0
267 self
.drag_box
.add(self
.icon
)
271 def set_type(self
, type, icon
= None):
272 """Change the icon and drag target to 'type'.
273 If 'icon' is given (as a GtkImage) then that icon is used,
274 otherwise an appropriate icon for the type is used."""
276 self
.icon
.set_from_pixbuf(icon
.get_pixbuf())
279 self
._set
_drag
_source
(type)
281 def _set_drag_source(self
, type):
282 if self
.document
.can_save_to_file():
283 targets
= [('XdndDirectSave0', 0, TARGET_XDS
)]
286 if self
.document
.can_save_to_selection():
287 targets
= targets
+ [(type, 0, TARGET_RAW
),
288 ('application/octet-stream', 0, TARGET_RAW
)]
291 raise Exception("Document %s can't save!" % self
.document
)
292 self
.drag_box
.drag_source_set(gdk
.BUTTON1_MASK | gdk
.BUTTON3_MASK
,
294 gdk
.ACTION_COPY | gdk
.ACTION_MOVE
)
296 def save_to_file_in_entry(self
):
297 """Call this when the user clicks on an OK button you provide."""
298 uri
= self
.entry
.get_text()
299 path
= get_local_path(escape(uri
))
302 if not self
.confirm_new_path(path
):
305 self
.set_sensitive(False)
307 self
.document
.save_to_file(path
)
309 self
.set_sensitive(True)
315 rox
.info(_("Drag the icon to a directory viewer\n"
316 "(or enter a full pathname)"))
318 def drag_begin(self
, drag_box
, context
):
319 self
.drag_in_progress
= 1
320 self
.destroy_on_drag_end
= 0
325 pixbuf
= self
.icon
.get_pixbuf()
327 drag_box
.drag_source_set_icon_pixbuf(pixbuf
)
329 # This can happen if we set the broken image...
331 traceback
.print_exc()
333 uri
= self
.entry
.get_text()
342 _write_xds_property(context
, leaf
)
344 def drag_data_get(self
, widget
, context
, selection_data
, info
, time
):
345 if info
== TARGET_RAW
:
347 self
.set_sensitive(False)
349 self
.document
.save_to_selection(selection_data
)
351 self
.set_sensitive(True)
354 _write_xds_property(context
, None)
358 _write_xds_property(context
, None)
360 if self
.drag_in_progress
:
361 self
.destroy_on_drag_end
= 1
365 elif info
!= TARGET_XDS
:
366 _write_xds_property(context
, None)
367 alert("Bad target requested!")
372 # Get the path that the destination app wants us to save to.
373 # If it's local, save and return Success
374 # (or Error if save fails)
375 # If it's remote, return Failure (remote may try another method)
376 # If no URI is given, return Error
378 uri
= _read_xds_property(context
, False)
380 path
= get_local_path(uri
)
382 if not self
.confirm_new_path(path
):
386 self
.set_sensitive(False)
388 self
.document
.save_to_file(path
)
390 self
.set_sensitive(True)
391 self
.data_sent
= True
394 self
.data_sent
= False
399 to_send
= 'F' # Non-local transfer
401 alert("Remote application wants to use " +
402 "Direct Save, but I can't read the " +
403 "XdndDirectSave0 (type text/plain) " +
406 selection_data
.set(selection_data
.target
, 8, to_send
)
409 _write_xds_property(context
, None)
410 path
= get_local_path(uri
)
418 def confirm_new_path(self
, path
):
419 """User wants to save to this path. If it's different to the original path,
420 check that it doesn't exist and ask for confirmation if it does.
421 If document.save_last_stat is set, compare with os.stat for an existing file
422 and warn about changes.
423 Returns true to go ahead with the save."""
424 if not os
.path
.exists(path
):
426 if path
== self
.initial_uri
:
427 if self
.document
.save_last_stat
is None:
428 return True # OK. Nothing to compare with.
429 last
= self
.document
.save_last_stat
432 if stat
.st_mode
!= last
.st_mode
:
433 msg
.append(_("Permissions changed from %o to %o.") % \
434 (last
.st_mode
, stat
.st_mode
))
435 if stat
.st_size
!= last
.st_size
:
436 msg
.append(_("Size was %d bytes; now %d bytes.") % \
437 (last
.st_size
, stat
.st_size
))
438 if stat
.st_mtime
!= last
.st_mtime
:
439 msg
.append(_("Modification time changed."))
441 return True # No change detected
442 return rox
.confirm("File '%s' edited by another program since last load/save. "
443 "Really save (discarding other changes)?\n\n%s" %
444 (path
, '\n'.join(msg
)), g
.STOCK_DELETE
)
445 return rox
.confirm(_("File '%s' already exists -- overwrite it?") % path
,
446 g
.STOCK_DELETE
, _('_Overwrite'))
448 def set_uri(self
, uri
):
449 """Data is safely saved somewhere. Update the document's URI and save_last_stat (for local saves).
451 path
= get_local_path(uri
)
453 self
.document
.save_last_stat
= os
.stat(path
) # Record for next time
454 self
.document
.set_uri(uri
)
456 def drag_end(self
, widget
, context
):
457 self
.drag_in_progress
= 0
458 if self
.destroy_on_drag_end
:
462 self
.document
.save_done()
464 class SaveBox(g
.Dialog
):
465 """A SaveBox is a GtkDialog that contains a SaveArea and, optionally, a Discard button.
466 Calls rox.toplevel_(un)ref automatically.
470 def __init__(self
, document
, uri
, type = 'text/plain', discard
= False):
471 """See SaveArea.__init__.
472 If discard is True then an extra discard button is added to the dialog."""
473 g
.Dialog
.__init
__(self
)
474 self
.set_has_separator(False)
476 self
.add_button(g
.STOCK_CANCEL
, g
.RESPONSE_CANCEL
)
477 self
.add_button(g
.STOCK_SAVE
, g
.RESPONSE_OK
)
478 self
.set_default_response(g
.RESPONSE_OK
)
481 discard_area
= g
.HButtonBox()
483 def discard_clicked(event
):
486 button
= rox
.ButtonMixed(g
.STOCK_DELETE
, _('_Discard'))
487 discard_area
.pack_start(button
, False, True, 2)
488 button
.connect('clicked', discard_clicked
)
489 button
.unset_flags(g
.CAN_FOCUS
)
490 button
.set_flags(g
.CAN_DEFAULT
)
491 self
.vbox
.pack_end(discard_area
, False, True, 0)
492 self
.vbox
.reorder_child(discard_area
, 0)
494 discard_area
.show_all()
496 self
.set_title(_('Save As:'))
497 self
.set_position(g
.WIN_POS_MOUSE
)
498 self
.set_wmclass('savebox', 'Savebox')
499 self
.set_border_width(1)
501 # Might as well make use of the new nested scopes ;-)
502 self
.set_save_in_progress(0)
503 class BoxedArea(SaveArea
):
504 def set_uri(area
, uri
):
505 SaveArea
.set_uri(area
, uri
)
512 def set_sensitive(area
, sensitive
):
514 # Might have been destroyed by now...
515 self
.set_save_in_progress(not sensitive
)
516 SaveArea
.set_sensitive(area
, sensitive
)
517 save_area
= BoxedArea(document
, uri
, type)
518 self
.save_area
= save_area
521 self
.build_main_area()
525 # Have to do this here, or the selection gets messed up
526 save_area
.entry
.grab_focus()
527 g
.Editable
.select_region(save_area
.entry
, i
, -1) # PyGtk bug
528 #save_area.entry.select_region(i, -1)
530 def got_response(widget
, response
):
531 if self
.save_in_progress
:
533 document
.save_cancelled()
535 rox
.report_exception()
537 if response
== g
.RESPONSE_CANCEL
:
539 elif response
== g
.RESPONSE_OK
:
540 self
.save_area
.save_to_file_in_entry()
541 elif response
== g
.RESPONSE_DELETE_EVENT
:
544 raise Exception('Unknown response!')
545 self
.connect('response', got_response
)
548 self
.connect('destroy', lambda w
: rox
.toplevel_unref())
550 def set_type(self
, type, icon
= None):
551 """See SaveArea's method of the same name."""
552 self
.save_area
.set_type(type, icon
)
554 def build_main_area(self
):
555 """Place self.save_area somewhere in self.vbox. Override this
556 for more complicated layouts."""
557 self
.vbox
.add(self
.save_area
)
559 def set_save_in_progress(self
, in_progress
):
560 """Called when saving starts and ends. Shade/unshade any widgets as
561 required. Make sure you call the default method too!
562 Not called if box is destroyed from a recursive mainloop inside
563 a save_to_* function."""
564 self
.set_response_sensitive(g
.RESPONSE_OK
, not in_progress
)
565 self
.save_in_progress
= in_progress
567 class StringSaver(SaveBox
, Saveable
):
568 """A very simple SaveBox which saves the string passed to its constructor."""
569 def __init__(self
, string
, name
):
570 """'string' is the string to save. 'name' is the default filename"""
571 SaveBox
.__init
__(self
, self
, name
, 'text/plain')
574 def save_to_stream(self
, stream
):
575 stream
.write(self
.string
)
577 class SaveFilter(Saveable
):
578 """This Saveable runs a process in the background to generate the
579 save data. Any python streams can be used as the input to and
580 output from the process.
582 The output from the subprocess is saved to the output stream (either
583 directly, for fileno() streams, or via another temporary file).
585 If the process returns a non-zero exit status or writes to stderr,
586 the save fails (messages written to stderr are displayed).
592 def set_stdin(self
, stream
):
593 """Use 'stream' as stdin for the process. If stream is not a
594 seekable fileno() stream then it is copied to a temporary file
595 at this point. If None, the child process will get /dev/null on
597 if stream
is not None:
598 if hasattr(stream
, 'fileno') and hasattr(stream
, 'seek'):
603 self
.stdin
= tempfile
.TemporaryFile()
604 shutil
.copyfileobj(stream
, self
.stdin
)
608 def save_to_stream(self
, stream
):
609 from processes
import PipeThroughCommand
611 assert not hasattr(self
, 'child_run') # No longer supported
613 self
.process
= PipeThroughCommand(self
.command
, self
.stdin
, stream
)
617 def save_cancelled(self
):
618 """Send SIGTERM to the child processes."""