Index: pitivi/pitivi.py
===================================================================
--- pitivi/pitivi.py	(revision 1020)
+++ pitivi/pitivi.py	(working copy)
@@ -39,39 +39,74 @@
 from gettext import gettext as _
 
 class Pitivi(gobject.GObject):
-    """
-    Pitivi's main class
+    """Pitivi's main class.
 
-    Signals:
-      new-project : A new project has been loaded, the Project object is given
-      closing-project : Pitivi wishes to close the project, callbacks return False
-                if they don't want the project to be closed, True otherwise
-      not-project : The given uri is not a project file
-      shutdown : PiTiVi has shutdown
+    This class is mainly responsible for managing Pitivi projects, controling
+    construction and destruction of the UI, and running the mainloop.
 
-    """
+    Signals for other classes to listen for:
+     _ new-project-loaded : A new project has been loaded, the Project object
+       is the extra argument.
+     _ close-project-requested : Pitivi wishes to close the project, callbacks
+       should return False if they want the project not to be closed
+       immediately, True otherwise.
+     _ save-project-requested : Pitivi wishes to save a project, but doesn't
+       have a filename to save as.
+     _ confirm-overwrite-file-changed : The project has been requested to save
+       itself, but the project file has changed since the last time it was
+       saved (or, since it was loaded).  The path is supplied as an extra
+       argument to the signal.  Callbacks should return True if the existing
+       path should be overwritten.
+     _ open-project-failed : An attempt to load a project failed.  The uri of
+       the project is the first extra argument, and a string giving the reason
+       for the failure is the second argument.
+
+    Signals used internally:
+     _ request-new-project: Called to request opening of a new project
+       (replacing the current one).  No arguments.
+     _ request-load-project: Called to request opening of an existing project
+       (replacing the current one).  1 argument: the uri to open.
+
+   """
     __gsignals__ = {
-        "new-project" : ( gobject.SIGNAL_RUN_LAST,
-                          gobject.TYPE_NONE,
-                          (gobject.TYPE_PYOBJECT, )),
-        "closing-project" : ( gobject.SIGNAL_RUN_LAST,
-                              gobject.TYPE_BOOLEAN,
-                              (gobject.TYPE_PYOBJECT, )),
-        "not-project" : ( gobject.SIGNAL_RUN_LAST,
-                          gobject.TYPE_NONE,
-                          (gobject.TYPE_STRING, )),
-        "shutdown" : ( gobject.SIGNAL_RUN_LAST,
-                       gobject.TYPE_NONE,
-                       ( ))
+        "new-project-loaded" : ( gobject.SIGNAL_RUN_LAST,
+                                 gobject.TYPE_NONE,
+                                 (gobject.TYPE_PYOBJECT, )),
+        "close-project-requested" : ( gobject.SIGNAL_RUN_LAST,
+                                      gobject.TYPE_BOOLEAN,
+                                      (gobject.TYPE_PYOBJECT, )),
+        "save-project-requested" : ( gobject.SIGNAL_RUN_LAST,
+                                     gobject.TYPE_NONE,
+                                     ()),
+        "confirm-overwrite-file-changed" : ( gobject.SIGNAL_RUN_LAST,
+                                             gobject.TYPE_BOOLEAN,
+                                             (gobject.TYPE_STRING, )),
+        "open-project-failed" : ( gobject.SIGNAL_RUN_LAST,
+                                  gobject.TYPE_NONE,
+                                  (gobject.TYPE_STRING, gobject.TYPE_STRING, )),
+
+
+        "request-new-project" : ( gobject.SIGNAL_RUN_LAST,
+                                  gobject.TYPE_NONE,
+                                  ()),
+        "request-load-project" : ( gobject.SIGNAL_RUN_LAST,
+                                  gobject.TYPE_NONE,
+                                  ()),
+        "request-shutdown" : ( gobject.SIGNAL_RUN_LAST,
+                               gobject.TYPE_NONE,
+                               ()),
         }
 
     project = None
 
     def __init__(self, use_ui=True, *args, **kwargs):
+        """Initialize pitivi, using the command line arguments.
+
         """
-        initialize pitivi with the command line arguments
-        """
-        gst.log("starting up pitivi...")
+        self.pendingAction = None
+        self.pendingActionArgs = None
+
+        gst.log("Starting up pitivi...")
         gobject.GObject.__init__(self)
 
         self._use_ui = use_ui
@@ -92,65 +127,191 @@
 
         self.playground = PlayGround()
         self.current = Project(_("New Project"))
+        self._connectToProject()
         self.effects = Magician()
 
         if self._use_ui:
             # we're starting a GUI for the time being
             self.gui = mainwindow.PitiviMainWindow()
             self.gui.show()
+        self.connect_object("request-new-project", Pitivi.newProject, self)
+        self.connect_object("request-load-project", Pitivi.loadProjectByUri, self)
+        self.connect_object("request-shutdown", Pitivi.requestShutdown, self)
 
-    def loadProject(self, uri=None, filepath=None):
-        """ Load the given file through it's uri or filepath """
-        gst.info("uri:%s, filepath:%s" % (uri, filepath))
-        if not uri and not filepath:
-            self.emit("not-project", "")
+    def _connectToProject(self):
+        """Create a new project, and connect to its signals.
+
+        """
+        self.current.connect("save-project-requested",
+                             self._saveProjectRequestedCb)
+        self.current.connect("confirm-overwrite-file-changed",
+                             self._confirmOverwriteFileChangedCb)
+
+    def _saveProjectRequestedCb(self, unused_project):
+        """Handle a save-project-requested signal from a project.
+
+        This simply passes the signal on to anyone listening for the equivalent
+        signal from this object.  This is useful because it prevents listeners
+        for the signal having to register with each new project.
+
+        """
+        return self.emit("save-project-requested")
+
+    def _confirmOverwriteFileChangedCb(self, project, uri):
+        """Handle a confirm-overwrite-file-changed signal from a project.
+
+        This simply passes the signal on to anyone listening for the equivalent
+        signal from this object.  This is useful because it prevents listeners
+        for the signal having to register with each new project.
+
+        """
+        return self.emit(signal, uri)
+
+    def newProject(self):
+        """Start up a new blank project.
+        
+        """
+        # If there's a running project we must close it first.
+        if not self._closeCurrentProject("request-new-project"):
             return
-        if filepath:
-            uri = "file://" + filepath
-        # is the given filepath a valid pitivi project
-        if not file_is_project(uri):
-            self.emit("not-project", uri)
+
+        self.playground.pause()
+        self.current = Project(_("New Project"))
+        self._connectToProject()
+        self.emit("new-project-loaded", self.current)
+
+    def loadProjectByFilepath(self, filepath):
+        """Load a project from a given filepath, replacing the current one.
+
+        `filepath` is the filepath to load the project from.
+        
+        """
+        self.loadProjectByUri("file://" + filepath)
+
+    def loadProjectByUri(self, uri):
+        """Load a project from a given URI, replacing the current one.
+
+        `uri` is the uri to load the project from.
+
+        """
+        # Close any current project
+        if not self._closeCurrentProject("request-load-project", uri):
             return
-        # if current project, try to close it
-        if self._closeRunningProject():
-            self.current = Project(uri)
-            self.emit("new-project", self.current)
 
-    def _closeRunningProject(self):
-        """ close the current project """
-        gst.info("closing running project")
+        gst.info("Loading project from %s" % uri)
+
+        # Check if the given filepath is a valid pitivi project
+        new_project = Project(uri=uri)
+        failure_reason = new_project.load()
+        if failure_reason is not None:
+            self.emit("open-project-failed", uri, failure_reason)
+            return
+
+        self.current = new_project
+        self._connectToProject()
+        self.emit("new-project-loaded", self.current)
+
+    def cancelPendingAction(self):
+        """Cancel the action which was pending.
+        
+        This is used to cancel the action which was in progress when request
+        to close the current project was denied (probably because the user
+        was presented with a question about whether to save the project or
+        not).
+
+        """
+        self.pendingAction = None
+        self.pendingActionArgs = None
+
+    def resumePendingAction(self):
+        """Resume the action which was pending.
+        
+        This is used to resume the action which was in progress when request
+        to close the current project was denied (probably because the user
+        was presented with a question about whether to save the project or
+        not).
+
+        If no action was pending (or the action has already been cancelled with
+        cancelPendingAction), this does nothing.
+
+        """
+        if self.pendingAction:
+            pendingAction = self.pendingAction
+            pendingActionArgs = self.pendingActionArgs
+            self.pendingAction = None
+            self.pendingActionArgs = None
+            self.emit(pendingAction, *pendingActionArgs)
+
+    def closeCurrentProjectForce(self):
+        """Force the current project to close.
+
+        This will close the current project immediately, even if it has unsaved
+        modifications.
+
+        """
+        gst.info("Closing project")
         if self.current:
-            if not self.emit("closing-project", self.current):
-                return False
             self.playground.pause()
             self.current = None
+
+    def _closeCurrentProject(self, pendingAction, *args):
+        """Close the current project.
+
+        If the project is successfully closed (or not open), returns True.
+        Otherwise, returns False.
+
+        pendingAction is the name of a signal to call, with the arguments
+        supplied in *args, if this method returns False, but the condition
+        preventing immediate closing is later resolved.
+        
+        """
+        if self.current is None:
+            return True
+
+    def requestShutdown(self):
+        """Request that PiTiVi shuts itself down.
+
+        This request may result in PiTiVi shutting down immediately, or may
+        result in a "closing-project" signal being sent (if there are unsaved
+        changes).
+
+        """
+        gst.info("shut down requested")
+        if not self._closeCurrentProject("request-shutdown"):
+            return True
+
+        gst.info("Sending close-project-requested signal, with pendingAction=%s, args=%s" %
+                 (pendingAction, repr(args)))
+        self.pendingAction = pendingAction
+        self.pendingActionArgs = args
+        if not self.emit("close-project-requested", self.current):
+            return False
+
+        self.pendingAction = None
+        self.pendingActionArgs = None
+        self.closeCurrentProjectForce()
         return True
 
-    def newBlankProject(self):
-        """ start up a new blank project """
-        # if there's a running project we must close it
-        if self._closeRunningProject():
-            self.playground.pause()
-            self.current = Project(_("New Project"))
-            self.emit("new-project", self.current)
-
-    def shutdown(self):
-        """ close PiTiVi """
-        gst.debug("shutting down")
-        # we refuse to close if we're running a user interface and the user
-        # doesn't want us to close the current project.
-        if self._use_ui and not self._closeRunningProject():
-            gst.warning("Not closing since running project doesn't want to close")
-            return
         self.threads.stopAllThreads()
         self.playground.shutdown()
         instance.PiTiVi = None
         self.emit("shutdown")
 
-def shutdownCb(pitivi):
-    gst.debug("Exiting main loop")
-    gtk.main_quit()
+    def shutdownCb(pitivi):
+        gst.debug("Exiting main loop")
+        gtk.main_quit()
+        return False
 
+    def forceShutdown(self):
+        """Force PiTiVi to itself down immediately.
+
+        """
+        gst.info("shutting down")
+        self.closeCurrentProjectForce()
+        self.playground.shutdown()
+        gst.info("Exiting main loop")
+        gtk.main_quit()
+
 def main(argv):
     check.initial_checks()
     ptv = Pitivi(argv)
Index: pitivi/project.py
===================================================================
--- pitivi/project.py	(revision 1020)
+++ pitivi/project.py	(working copy)
@@ -25,6 +25,9 @@
 """
 
 import os.path
+import tempfile
+import time
+import math
 import gobject
 import gst
 from timeline.timeline import Timeline
@@ -36,20 +39,38 @@
 from gettext import gettext as _
 
 class Project(gobject.GObject):
-    """ The base class for PiTiVi projects """
+    """The base class for PiTiVi projects
 
+    Signals:
+     _ save-project-requested : The project has been requested to
+       save itself, but needs a URI to save to, or other settings.
+     _ confirm-overwrite-file-changed : The project has been requested to save
+       itself, but the project file has changed since the last time it was
+       saved (or, since it was loaded).  The path is supplied as an extra
+       argument to the signal.  Callbacks should return True if the existing
+       path should be overwritten.
+
+    """
     __gsignals__ = {
         "settings-changed" : ( gobject.SIGNAL_RUN_LAST,
                                gobject.TYPE_NONE,
-                               (  ))
+                               ()),
+        "save-project-requested" : ( gobject.SIGNAL_RUN_LAST,
+                                     gobject.TYPE_NONE,
+                                     ()),
+        "confirm-overwrite-file-changed" : ( gobject.SIGNAL_RUN_LAST,
+                                             gobject.TYPE_BOOLEAN,
+                                             (gobject.TYPE_STRING, )),
         }
 
     def __init__(self, name="", uri=None):
+        """Initialise a new project.
+
+        Even if a URI is specified, this does not load the project.
+        Loading is performed only when the load method is explicitly called.
+
         """
-        name : the name of the project
-        uri : the uri of the project
-        """
-        gst.log("name:%s, uri:%s" % (name, uri))
+        gst.log("Making new project, name:%s, uri:%s" % (name, uri))
         gobject.GObject.__init__(self)
         self.name = name
         self.settings = None
@@ -59,16 +80,154 @@
         self.timeline = None
         self.timelinebin = None
         self.settingssigid = 0
-        self._load()
-
-    def _load(self):
-        """ loads the project from a file """
-        if self.timeline:
-            return
+        self.modified = False
+        self.last_mtime = None # mtime of saved project
+        self.first_modification_time = None
         self.timeline = Timeline(self)
-        if self.uri:
-            raise NotImplementedError
+        self.clearModificationState()
 
+    def clearModificationState(self):
+        """Clear the modified state.
+
+        This is used, for example, after a save to indicate that there are no
+        longer unsaved modifications.
+
+        """
+        self.modified = False
+        self.first_modification_time = None
+
+    def registerModification(self):
+        """Register that a modification has occurred.
+
+        TODO - expand this into a framework which also handles undo/redo.
+
+        """
+        if not self.modified:
+            self.modified = True
+            self.first_modification_time = time.time()
+
+    def secondsSinceFirstModification(self):
+        """Return the number of seconds since the first modification occurred.
+
+        This returns an integer number of seconds, rounded up, or 0 if no
+        modifications have occurred.
+
+        """
+        if self.first_modification_time is None:
+            return 0
+        return int(math.ceil(time.time() - self.first_modification_time))
+
+    def load(self):
+        """Loads the project.
+
+        The project's URI must be set before this is called.
+
+        """
+        assert self.uri is not None
+        assert self.uri.startswith("file:/")
+        filename = self.uri[5:]
+        self.last_mtime = os.path.getmtime(filename)
+
+        # TODO
+        raise NotImplementedError
+
+        self.clearModificationState()
+
+    def _writeToFd(self, fd):
+        """Write the project to the given file descriptor.
+
+        """
+        assert self.timeline is not None
+        self.timeline.writeToFd(fd)
+
+    def _save(self, uri):
+        """Internal save function.
+        
+        """
+        assert uri is not None
+        assert uri.startswith("file:/")
+        newfilename = uri[5:]
+
+        currentfilename = None
+        if (self.uri is not None and
+            self.uri.startswith("file:/")):
+            currentfilename = self.uri[5:]
+
+        # If we're saving to the same URI, check if it's been changed
+        # externally since the last save, and ask the UI what to do if so.
+        if (currentfilename is not None and
+            os.path.exists(currentfilename) and
+            os.path.exists(newfilename) and
+            os.path.samefile(currentfilename, newfilename) and
+            self.last_mtime != os.path.getmtime(newfilename)):
+            if not self.emit('confirm-overwrite-file-changed', uri):
+                return
+
+        targetdir = os.path.dirname(newfilename)
+        targetname = os.path.basename(newfilename)
+        assert targetname != ''
+        (tmpfd, tmpfilename) = tempfile.mkstemp('.tmp', targetname, targetdir)
+        try:
+            fd = file(tmpfilename, "wb")
+            try:
+                self._writeToFd(fd)
+            finally:
+                fd.close()
+            try:
+                os.remove(newfilename)
+            except OSError:
+                pass
+            os.rename(tmpfilename, newfilename)
+        except:
+            try:
+                os.remove(tmpfilename)
+            except OSError:
+                pass
+            raise
+
+        self.last_mtime = os.path.getmtime(newfilename)
+        self.clearModificationState()
+
+    def save(self):
+        """Save the project.
+
+        If the project has a URI and all necessary settings, this immediately
+        saves the project.
+
+        If the project does not have a URI or some of the necessary settings,
+        this emits the save-project-requested signal, and doesn't save the
+        project.
+
+        """
+        if self.uri is None:
+            self.emit('save-project-requested')
+        else:
+            self._save(self.uri)
+
+    def saveAs(self, uri):
+        """Saves the project to the given URI.
+        
+        This also sets the project URI to the given URI, so future calls to
+        save() will use this URI.
+        
+        """
+        assert uri is not None
+        assert uri.startswith("file:/")
+
+        if (self.uri is not None and
+            self.uri.startswith("file:/") and
+            os.path.samefile(self.uri[5:], uri[5:])):
+            self.save()
+        else:
+            self._save(uri)
+            self.uri = uri
+
+    def hasUnsavedModifications(self):
+        """Return True iff the project has been modified since saving.
+
+        """
+        return self.modified
+
     def getBin(self):
         """ returns the SmartTimelineBin of the project """
         if not self.timeline:
@@ -77,19 +236,7 @@
             self.timelinebin = SmartTimelineBin(self)
         return self.timelinebin
 
-    def _save(self, filename):
-        """ internal save function """
-        # TODO
-        pass
 
-    def save(self):
-        """ Saves the project to the project's current file """
-        self._save(self.uri)
-
-    def saveAs(self, filename):
-        """ Saves the project to the given file name """
-        self._save(filename)
-
     # setting methods
     def _settingsChangedCb(self, unused_settings):
         self.emit('settings-changed')
