# vPresent is an immersive presentation creation and display application.
# Copyright (C) 2007-2008 by Infiscape Corporation
#
# This program is free software; you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the Free
# Software Foundation; either version 2 of the License, or (at your option)
# any later version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.

import glob
import logging
import mimetypes
import os
import os.path
pj = os.path.join
import pickle
import shutil
import stat
import sys
import tempfile
import time
import traceback
import types

from PyQt4 import QtCore, QtGui
import osg2 as osg

import opendocument
import opendocument.presentation as presentation
import gui.opensg as opensg
import gui.background as background
import gui.background.editdialog

import gui.util as util
import gui.util.environment as environ
const = util.const
import gui.util.docmaker

import gui.fileio.archive
import gui.fileio.standalone
import gui.fileio.presentation

import gui.navstatus
import gui.prefs as prefs
import gui.cluster.opensg

import aboutui
import vpresentui
import licenseui

class DocumentHolder(QtCore.QObject):
   '''
   This class aggregates information about a document and presents a facade
   to the code using it that makes saving, renaming, and closing documents
   easy.
   '''

   sBackupTimer = QtCore.QTimer()

   def __init__(self, fileName, workDir, cleaner, document, parent=None):
      QtCore.QObject.__init__(self, parent)
      self.fileName  = fileName
      self.workDir = workDir
      self.cleaner   = cleaner
      self.document  = document

      tmp_ext = '.vptmp'

      if self.fileName is not None:
         dir_name, file_name = os.path.split(self.fileName)
         if dir_name == '':
            dir_name = '.'

         (self.backupFileHandle, self.backupFileName) = \
            tempfile.mkstemp(tmp_ext, file_name + ',', dir_name)
         self.backupFile = os.fdopen(self.backupFileHandle, 'w+b')
      else:
         (self.backupFileHandle, self.backupFileName) = \
            tempfile.mkstemp(tmp_ext)
         self.backupFile = os.fdopen(self.backupFileHandle, 'w+b')

      self.hasUnsavedChanges = not self.document.getUndoStack().isClean()

      self.connect(self.document.getUndoStack(),
                   QtCore.SIGNAL("cleanChanged(bool)"),
                   self.__documentChanged)
      self.connect(self.sBackupTimer, QtCore.SIGNAL("timeout()"),
                   self.dumpState)

      # Start the backup timer.
      if not self.sBackupTimer.isActive():
         env = environ.Environment()
         settings = env.getSettings()
         default_backup_interval = 60
         try:
            backup_interval = int(settings.getValue('general/backup_interval', default_backup_interval))
         except:
            backup_interval = default_backup_interval
         self.sBackupTimer.start(backup_interval*1000)

   def __del__(self):
      if self.workDir is not None:
         self.close(self.hasUnsavedChanges)

   def getBackupTimer():
      return DocumentHolder.sBackupTimer

   getBackupTimer = staticmethod(getBackupTimer)

   def setBackupInterval(msec):
      DocumentHolder.sBackupTimer.setInterval(msec)

   setBackupInterval = staticmethod(setBackupInterval)

   def getFileName(self):
      return self.fileName

   def save(self, newFileName = None):
      if newFileName is not None:
         self.fileName = newFileName

         old_backup_file_name = os.path.basename(self.backupFileName)
         new_backup_file_name = self.fileName + ',' + old_backup_file_name

         self.__closeBackup(True)

         flags = os.O_RDWR | os.O_CREAT | os.O_EXCL

         if hasattr(os, 'O_NOINHERIT'):
            flags |= os.O_NOINHERIT

         if hasattr(os, 'O_NOFOLLOW'):
            flags |= os.O_NOFOLLOW

         if hasattr(os, 'O_BINARY'):
            flags |= os.O_BINARY

         self.backupFileName   = new_backup_file_name
         self.backupFileHandle = os.open(self.backupFileName, flags, 0600)
         self.backupFile = os.fdopen(self.backupFileHandle, 'w+b')
         self.dumpState()

      env = environ.Environment()
      writer_class = \
         env.getDocWriterClassForExt(os.path.splitext(self.fileName)[1])
      writer = writer_class(self.fileName)
      writer.write(self.document, self.workDir)

   def close(self, deleteBackup = True):
      self.disconnect(self.sBackupTimer, QtCore.SIGNAL("timeout()"),
                      self.dumpState)

      self.document.close()
      self.document = None

      self.__closeBackup(deleteBackup)

      try:
         self.cleaner(self.workDir)
         self.workDir = None
      except:
         traceback.print_exc()

   def dumpState(self):
      if self.document is not None:
         self.backupFile.seek(0)
         self.backupFile.write(pickle.dumps(self.document))
         self.backupFile.truncate()

   def importResource(self, fileName, subDir="images"):
      """
      Import the given resource into the presentation and return its
      new relative path from the working dir.
      """
      file_name = os.path.abspath(fileName)
      leaf_name = os.path.split(file_name)[1]

      # Get the path on disk to copy the image to.
      resource_path = pj(self.workDir, subDir, leaf_name)
      # Construct the new relative path from the working directory.
      href = pj(subDir, leaf_name)
      if not os.path.exists(os.path.dirname(resource_path)):
         os.makedirs(os.path.dirname(resource_path))
      print "Import %s -> %s" % (file_name, resource_path)
      print "   href:", href
      shutil.copy2(file_name, resource_path)
      return href

   def __closeBackup(self, deleteBackup):
      self.backupFile.close()
      # XXX: Not needed anymore.
      #os.close(self.backupFileHandle)

      if deleteBackup:
         os.unlink(self.backupFileName)

      self.backupFileHandle = None
      self.backupFile       = None
      self.backupFileName   = None

   def __documentChanged(self, clean):
      self.hasUnsavedChanges = not clean

class OpenSGViewFilter(QtCore.QObject):
   def __init__(self, parent=None):
      QtCore.QObject.__init__(self, parent)
      self.mPage = None
      self.lastIntersect = None

   def setPage(self, page):
      self.mPage = page

   def eventFilter(self, watched, event):
      if isinstance(event, QtGui.QDragEnterEvent):
         if event.mimeData().hasFormat(opensg.Style.mime_type):
            event.accept()
            return True
         else:
            event.ignore()
      elif isinstance(event, QtGui.QDragMoveEvent):
         if self.dragMove(watched, event):
            event.accept()
            return True
         else:
            event.ignore()
            return False
      elif isinstance(event, QtGui.QDropEvent):
         if self.dropEvent(event):
            event.accept()
            return True
         else:
            event.ignore()
            return False
      return QtCore.QObject.eventFilter(self, watched, event)

   def dragMove(self, glWidget, event):
      #env = Environment()
      if not event.mimeData().hasFormat(opensg.Style.mime_type) or \
         self.mPage is None:
         return False

      line = glWidget.manager.calcViewRayVP(event.pos().x(), event.pos().y())

      #print "Current Page:", self.mPage
      scene = self.mPage.find('./dr3d:scene')
      if scene is not None:
         for c in scene._children:
            if c.getRootNode() is not None:
               vol = osg.DynamicVolume()
               c.getRootNode().getWorldVolume(vol)
               if vol.isValid() and vol.intersect(line):
                  self.lastIntersect = c
                  return True

      self.lastIntersect = None
      return False

   def dropEvent(self, event):
      if not event.mimeData().hasFormat(opensg.Style.mime_type) or \
         self.mPage is None:
         return False

      if self.lastIntersect is None:
         return False

      # Extract the data from the dragged object.
      style_data = event.mimeData().data(opensg.Style.mime_type)

      # Deserialzied the dragged object into the style name.
      data_stream = QtCore.QDataStream(style_data,
                                       QtCore.QIODevice.ReadOnly)
      style_str = QtCore.QString()
      data_stream >> style_str
      style_str.remove(0, len('style_name:'))
      style_name = str(style_str)

      env = environ.Environment()
      undo_stack = env.curDocument.document.getUndoStack()

      # Set the material name on the intersected object.
      assign_material = AssignMaterial(self.lastIntersect, style_name)
      undo_stack.push(assign_material)

      return True

class vPresent(QtGui.QMainWindow, vpresentui.Ui_MainWindow):
   MODE_EDIT = 0
   MODE_PLAY = 1

   def __init__(self, passiveWindow, parent = None):
      QtGui.QMainWindow.__init__(self, parent)

      self.pwin = passiveWindow
      self.mode = vPresent.MODE_EDIT

      env = environ.Environment()
      gui.fileio.archive.ReaderWriter.init(env)
      gui.fileio.standalone.ReaderWriter.init(env)

      self.unsavedSinceRestore = False
      DocumentHolder.getBackupTimer().setParent(self)

      for dt in env.getDocTypes():
         ext_mime_map = dt.getExtMimeMap()
         for e, mt in ext_mime_map.items():
            ext = '.' + e
            if not mimetypes.types_map.has_key(ext):
               mimetypes.add_type(mt, ext)

      env.fileStartDir = os.getcwd()
      self.curDocument = None

      self.glWidget = None
      self._displayWindow = None
      self.setupUi(self)
      self.loggers = []

      # This seems a little sloppy. It would be nice if there were a simpler
      # way of providing global access to either the status bar or the
      # navigation status widget. Then again, providing global access to
      # application resources/information is the primary purpose of the
      # Environment singleton.
      env = environ.Environment()
      env.navStatus = gui.navstatus.NavStatus()
      self.statusBar().addPermanentWidget(env.navStatus)

      mgr = self.glWidget.manager
      mgr.setWindow(self.pwin)
      # Initialize all sm internal data.
      mgr.setSceneRoot(None)
      self.connect(mgr, QtCore.SIGNAL("nearFarChanged(float,float)"),
                   self.onNearFarChanged)

      self.mCurrentPage = None
      self.backgroundModel = None
      self.setCurrentPage(None)
      self.presentationModel = None

      # Format tab used to edit scene objects.
      self.mFormatTab = None
      self.mSelectedObject = None

      # Update buttons since we started without a file.
      # NOTE: Do this after everything else so that we ensure the
      #       buttons get the correct state.
      self.__documentLoadedChanged(False)

   def init(self, splashScreen):
      env = environ.Environment()
      # If a presentation is specified on the command line, try to load it.
      if env.cmdOpts.presentation is not None:
         file_name = os.path.abspath(env.cmdOpts.presentation)
         msg = "Loading vPersent presentation %s"%(os.path.split(file_name)[1])
         splashScreen.showMessage(msg, QtCore.Qt.AlignRight, QtCore.Qt.white)

         self.__doOpenDocument(file_name)

   def setupUi(self, widget):
      vpresentui.Ui_MainWindow.setupUi(self, widget)

      self.undoGroup = QtGui.QUndoGroup()
      self.connect(self.undoGroup, QtCore.SIGNAL("cleanChanged(bool)"),
                   self.cleanStateChanged)

      undo_redo_sep = self.menuEdit.insertSeparator(self.actionCut)

      undo_action = self.undoGroup.createUndoAction(self)
      undo_action.setIcon(QtGui.QIcon(":/vpresent/gui/images/undo32.png"))
      undo_action.setShortcut(
         QtGui.QApplication.translate("MainWindow", "Ctrl+Z", None,
                                      QtGui.QApplication.UnicodeUTF8)
      )
      self.menuEdit.insertAction(undo_redo_sep, undo_action)

      redo_action = self.undoGroup.createRedoAction(self)
      redo_action.setIcon(QtGui.QIcon(":/vpresent/gui/images/redo32.png"))
      redo_action.setShortcut(
         QtGui.QApplication.translate("MainWindow", "Ctrl+Shift+Z", None,
                                      QtGui.QApplication.UnicodeUTF8)
      )
      self.menuEdit.insertAction(undo_redo_sep, redo_action)

      # Create a 'Recent Files' menu.
      self.recentMenu = QtGui.QMenu("Recent Files", self.menuFile)
      self.menuFile.insertMenu(self.actionRevert, self.recentMenu)
      self.menuFile.insertSeparator(self.actionRevert)

      # Get the recent files settings.
      default_max_recent_files = 5
      env = environ.Environment()
      settings = env.getSettings()
      if not settings.has_key('general/recent_files'):
         settings.add('general/recent_files', max_recent_files=default_max_recent_files)
      recent_files = settings.get('general/recent_files')

      # Get the maximum number of recent files to show.
      try:
         max_recent_files = int(recent_files['max_recent_files'])
      except:
         max_recent_files = default_max_recent_files
         recent_files['max_recent_files'] = max_recent_files

      # Add actions for all recent files.
      self.recentFileActs = []
      for num in xrange(max_recent_files):
         self.recentFileActs.append(QtGui.QAction(self))
         self.recentFileActs[num].setShortcut("Ctrl+%d"%(num+1))
         self.recentFileActs[num].setVisible(False)
         self.recentMenu.addAction(self.recentFileActs[num])
         self.connect(self.recentFileActs[num], QtCore.SIGNAL("triggered()"),
                      self.onOpenRecentFile)

      # Add a action to clear the recent files.
      self.recentMenu.addSeparator()
      self.recentMenu.addAction(self.actionClearRecent)
      self.connect(self.actionClearRecent, QtCore.SIGNAL("triggered()"),
                   self.onClearRecentFiles)
      self.__updateRecentFileActions()

      # Create the OpenSG widget.
      self.glWidget = opensg.OpenSGView(self.previewFrame)
      self.glWidget.setFocusPolicy(QtCore.Qt.StrongFocus)
      self.glWidget.setAcceptDrops(True)

      # Install an event filter to intercept drop events.
      self.glWidgetFilter = OpenSGViewFilter()
      self.glWidget.installEventFilter(self.glWidgetFilter)

      # Add a global callback that updates the OpenSG scene.
      env.updateGL = self.glWidget.updateGL
      env.manager = self.glWidget.manager

      # XXX: Create thumbnail generator.
      global gThumbnailGenerator
      gThumbnailGenerator = opensg.ThumbnailCreator(self.glWidget)


      preview_layout = QtGui.QHBoxLayout()
      preview_layout.addWidget(self.glWidget)
      self.previewFrame.setLayout(preview_layout)

      self.connect(self.statsCheckBox, QtCore.SIGNAL("stateChanged(int)"),
                   self.onToggleOpenSGStats)

      self.connect(self.actionNew, QtCore.SIGNAL("triggered()"),
                   self.onNewDocument)
      self.connect(self.actionOpen, QtCore.SIGNAL("triggered()"),
                   self.onOpenDocument)
      self.connect(self.actionSave, QtCore.SIGNAL("triggered()"),
                   self.onSaveDocument)
      self.connect(self.actionSave_As, QtCore.SIGNAL("triggered()"),
                   self.onSaveDocumentAs)
      self.connect(self.actionRevert, QtCore.SIGNAL("triggered()"),
                   self.onRevert)
      self.connect(self.actionClose, QtCore.SIGNAL("triggered()"),
                   self.onCloseDocument)
      self.connect(self.actionPreferences, QtCore.SIGNAL("triggered()"),
                   self.onEditPrefs)
      self.connect(self.actionLicenseTerms, QtCore.SIGNAL("triggered()"),
                   self.onLicense)
      self.connect(self.actionAbout_vPresent, QtCore.SIGNAL("triggered()"),
                   self.onAbout)

      # Connect presentation actions.
      self.connect(self.actionPlay, QtCore.SIGNAL("triggered()"),
                   self.onStartPresentation)
      self.playButton.setDefaultAction(self.actionPlay)
      self.connect(self.actionStop, QtCore.SIGNAL("triggered()"),
                   self.onStopPresentation)
      self.stopButton.setDefaultAction(self.actionStop)
      self.connect(self.actionClusterView, QtCore.SIGNAL("toggled(bool)"),
                   self.onConfigureServers)
      self.clusterViewButton.setDefaultAction(self.actionClusterView)
      self.connect(self.actionPrevious, QtCore.SIGNAL("triggered()"),
                   self.onPreviousSlide)
      self.previousButton.setDefaultAction(self.actionPrevious)
      self.connect(self.actionNext, QtCore.SIGNAL("triggered()"),
                   self.onNextSlide)
      self.nextButton.setDefaultAction(self.actionNext)
      self.connect(self.actionFirstSlide, QtCore.SIGNAL("triggered()"),
                   self.onFirstSlide)
      self.toStartButton.setDefaultAction(self.actionFirstSlide)
      self.connect(self.actionLastSlide, QtCore.SIGNAL("triggered()"),
                   self.onLastSlide)
      self.toEndButton.setDefaultAction(self.actionLastSlide)

      # Connect slide actions.
      self.connect(self.actionAddSlide, QtCore.SIGNAL("triggered()"),
                   self.onAddSlide)
      self.addSlideButton.setDefaultAction(self.actionAddSlide)
      self.connect(self.actionRemoveSlide, QtCore.SIGNAL("triggered()"),
                   self.onRemoveSlide)
      self.removeSlideButton.setDefaultAction(self.actionRemoveSlide)

      self.connect(self.actionShowAll, QtCore.SIGNAL("triggered()"),
                   self.onShowAll)
      self.showAllButton.setDefaultAction(self.actionShowAll)

      self.connect(self.actionResetView, QtCore.SIGNAL("triggered()"),
                   self.onResetView)
      self.resetViewButton.setDefaultAction(self.actionResetView)

      # Connect model actions.
      self.connect(self.actionInsertModel, QtCore.SIGNAL("triggered()"),
                   self.onInsertModel)
      self.insertModelButton.setDefaultAction(self.actionInsertModel)

      # Connect material actions.
      self.connect(self.actionAddMaterial, QtCore.SIGNAL("triggered()"),
                   self.onAddMaterial)
      self.addMaterialButton.setDefaultAction(self.actionAddMaterial)

      # Connect fisheye actions.
      self.connect(self.actionAddFisheye, QtCore.SIGNAL("triggered()"),
                   self.onAddFisheye)
      self.addFisheyeButton.setDefaultAction(self.actionAddFisheye)

      # Connect panorama actions.
      self.connect(self.actionAddPanorama, QtCore.SIGNAL("triggered()"),
                   self.onAddPanorama)
      self.addPanoramaButton.setDefaultAction(self.actionAddPanorama)

      # Connect billboard actions.
      self.connect(self.actionAddBillboard, QtCore.SIGNAL("triggered()"),
                   self.onAddBillboard)
      self.insertBillboardButton.setDefaultAction(self.actionAddBillboard)

      # Connect shape actions.
      self.connect(self.actionInsertSphere, QtCore.SIGNAL("triggered()"),
                   lambda: self.onInsertShape(InsertShape.SPHERE))
      self.connect(self.actionInsertBox, QtCore.SIGNAL("triggered()"),
                   lambda: self.onInsertShape(InsertShape.BOX))
      self.connect(self.actionInsertCylinder, QtCore.SIGNAL("triggered()"),
                   lambda: self.onInsertShape(InsertShape.CYLINDER))

      # Create a shape menu and tie it's lifetime to the insert shape button.
      shape_menu = QtGui.QMenu(self.insertShapeButton)
      shape_menu.addAction(self.actionInsertSphere)
      shape_menu.addAction(self.actionInsertBox)
      shape_menu.addAction(self.actionInsertCylinder)
      self.insertShapeButton.setMenu(shape_menu)

      # XXX: Work around a bug that opens the slide name editor over
      #      the slide thumbnail. The ListView should be able to get
      #      this value from it's model.
      self.slideThumbnailList.setIconSize(QtCore.QSize(64, 64))

      self.connect(self.glWidget, QtCore.SIGNAL("mousePressed"),
                   self.onMousePressed)

      self.connect(self.glWidget, QtCore.SIGNAL("mouseReleased()"),
                   self.onMouseReleased)

      self.connect(self.glWidget, QtCore.SIGNAL("keyPressed"),
                   self.onKeyPressed)

      # Setup design tab.
      self.backgroundList.setIconSize(QtCore.QSize(64, 64))

      # Connect add background actions.
      self.connect(self.actionCreateCubicBackground, QtCore.SIGNAL("triggered()"),
                   lambda: self.onAddBackground(background.AddPageStyle.CUBIC))
      self.connect(self.actionCreateMercatorBackground, QtCore.SIGNAL("triggered()"),
                   lambda: self.onAddBackground(background.AddPageStyle.MERCATOR))

      # Create a shape menu and tie it's lifetime to the insert shape button.
      bg_menu = QtGui.QMenu(self.addBackgroundBtn)
      bg_menu.addAction(self.actionCreateCubicBackground)
      bg_menu.addAction(self.actionCreateMercatorBackground)
      self.addBackgroundBtn.setMenu(bg_menu)

      # Setup a custom context menu callback.
      self.backgroundList.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
      self.connect(self.backgroundList,
                   QtCore.SIGNAL("customContextMenuRequested(QPoint)"),
                   self.onBackgroundContextMenu)

      self.actionRemoveBackground.setShortcuts(QtGui.QKeySequence.Delete)
      self.removeBackgroundBtn.setDefaultAction(self.actionRemoveBackground)
      self.editBackgroundBtn.setDefaultAction(self.actionEditBackground)
      self.connect(self.actionRemoveBackground, QtCore.SIGNAL("triggered()"),
                   self.onRemoveBackground)
      self.connect(self.actionEditBackground, QtCore.SIGNAL("triggered()"),
                   self.onEditBackground)

   def getCurrentPage(self):
      idx = self.slideThumbnailList.currentIndex()
      current_page = None
      if idx.isValid() and self.presentationModel is not None:
         current_page = self.presentationModel.data(idx, QtCore.Qt.UserRole)
      assert current_page == self.mCurrentPage
      return self.mCurrentPage

   def getCurrentBackground(self):
      idx = self.backgroundList.currentIndex()
      current_background = None
      if idx.isValid() and self.backgroundModel is not None:
         current_background = self.backgroundModel.data(idx, QtCore.Qt.UserRole)
      return current_background

   def onOpenRecentFile(self):
      action = self.sender()
      if action is not None:
         print "Load:", action.data().toString()
         file_name = str(action.data().toString())
         self.__doOpenDocument(file_name)

   def onClearRecentFiles(self):
      env = environ.Environment()
      settings = env.getSettings()
      recent_settings = settings.get('general/recent_files')
      
      # Remove all extra recent files.
      num_recent_files = len(recent_settings.getChildren())
      for num in xrange(num_recent_files):
         recent_settings.removeChild(0)

      # Save changes.
      settings.save()
      self.__updateRecentFileActions()

   def __addRecentFile(self, file_name):
      env = environ.Environment()
      settings = env.getSettings()
      recent_settings = settings.get('general/recent_files')
      
      # If file is already in recent list, dont add it.
      for c in recent_settings.getChildren():
         if c.value.strip() == file_name:
            return

      # Get the max number of recent files we should save.
      max_recent_files = int(recent_settings['max_recent_files'])

      # Add new recent file to the end of the list.
      new_recent = util.settings.Setting(name="recent_file", value=file_name)
      recent_settings.addChild(new_recent)

      # Remove all extra recent files.
      num_recent_files = len(recent_settings.getChildren())
      while num_recent_files > max_recent_files:
         recent_settings.removeChild(0)
         num_recent_files = len(recent_settings.getChildren())

      # Save changes.
      settings.save()
      self.__updateRecentFileActions()

   def __updateRecentFileActions(self):
      env = environ.Environment()
      settings = env.getSettings()
      recent_settings = settings.get('general/recent_files')
      recent_files = recent_settings.getChildren()
      num_recent_files = len(recent_files)
      max_recent_files = int(recent_settings['max_recent_files'])

      # Reverse list so that newest file is at the top.
      recent_files.reverse()
      for i in xrange(num_recent_files):
         file_name = recent_files[i].value
         leaf_name = os.path.split(file_name)[1]
         text = "&%d %s" % (i+1, leaf_name)
         self.recentFileActs[i].setText(text)
         self.recentFileActs[i].setData(QtCore.QVariant(file_name.strip()))
         self.recentFileActs[i].setVisible(True)

      # Hide all unused actions.
      for j in xrange(num_recent_files, max_recent_files):
         self.recentFileActs[j].setVisible(False)

      # Only allow clearing if there are recent files.
      self.actionClearRecent.setEnabled(num_recent_files > 0)

   def onNearFarChanged(self, newNear, newFar):
      self._curNear = newNear
      self._curFar  = newFar

      if self.mCurrentPage is not None:
         self.mCurrentPage.updateBackgroundSize(newFar)

   def onMousePressed(self, event):
      # Perform an intersection test by shooting a ray into the scene under
      # the mouse pointer.
      if (QtCore.Qt.LeftButton == event.button() and
          self.mCurrentPage is not None          and
          self.mode == vPresent.MODE_EDIT):

         scene = self.mCurrentPage.find('./dr3d:scene')
         if scene is None:
            return

         # This gives us a ray into the scene in view platform coordinates.
         line = self.glWidget.manager.calcViewRayVP(event.x(), event.y())

         min_dist = float(sys.maxint)
         intersected_obj = None

         # Create just about everything once before we go through the loop
         # below. Creation operations of C++ objects from Python can be
         # expensive.
         world_xform = osg.Matrix()
         obj_M_vp = osg.Matrix()
         dvol = osg.DynamicVolume()
         pos = osg.Pnt3f()
         dir = osg.Vec3f()

         # Ensure that volumes are up to date.
         osg.commitChanges()

         for c in scene:
            # Skip all lights for now.
            if c.element.name == "dr3d:light":
               continue

            root = c.getRootNode()
            if root is None:
               print "WARNING: Encountered a scene object node with empty root: ", c
               continue

            if root.getParent() is None:
               print "WARNING: Encountered a scene object node with no parent"
               continue

            root.updateVolume()
            # world_xform == vp_M_obj
            root.getParent().getToWorld(world_xform)
            obj_M_vp.invertFrom(world_xform)

            # Create a new ray in object coordinates by transforming line from
            # view platform coordinates into that of the current object.
            obj_M_vp.multFullMatrixPnt(line.getPosition(), pos)
            obj_M_vp.multMatrixVec(line.getDirection(), dir)
            cur_line = osg.Line(pos, dir)

            root.getParent().getWorldVolume(dvol)

            if dvol.intersect(line):
               action = osg.IntersectAction.create(cur_line)
               action.apply(root)
               if action.didHit() and action.getHitT() < min_dist:
                  min_dist = action.getHitT()
                  intersected_obj = c

         self.onSelected(intersected_obj)

   def onKeyPressed(self, event):
      if event.key() == QtCore.Qt.Key_Delete:
         if self.mSelectedObject is not None:
            print "DELETING"
            page = self.getCurrentPage()
            assert page is not None
            delete_scene_object = \
               DeleteSceneObject(self.presentationModel, page,
                                 self.mSelectedObject)
            undo_stack = self.curDocument.document.getUndoStack()
            undo_stack.push(delete_scene_object)
      # Switch to the previous slide.
      elif event.key() == QtCore.Qt.Key_PageUp:
         self.changeCurrentSlide(-1)
      # Switch to the next slide.
      elif event.key() == QtCore.Qt.Key_PageDown:
         if not self.changeCurrentSlide(1):
            self.endPresentation()
      # Switch to the first slide.
      elif event.key() == QtCore.Qt.Key_Home:
         self.changeToFirstSlide()
      # Switch to the last slide.
      elif event.key() == QtCore.Qt.Key_End:
         self.changeToLastSlide()

   def changeCurrentSlide(self, offset):
      '''
      Attempts to change the currently selected slide by the given offset
      (either positive or negative). A positive offset increases the row
      value in the selection model, and a negative offset value decreases it.
      '''
      changed = False

      if offset != 0:
         cur_index  = self.slideThumbnailList.currentIndex()
         new_index = cur_index.sibling(cur_index.row() + offset,
                                       cur_index.column())

         if new_index.isValid():
            self.slideThumbnailList.selectionModel().setCurrentIndex(
               new_index, QtGui.QItemSelectionModel.ClearAndSelect
            )
            changed = True

      return changed

   def changeToFirstSlide(self):
      index = self.slideThumbnailList.currentIndex()
      self.changeCurrentSlide(-index.row())

   def changeToLastSlide(self):
      index  = self.slideThumbnailList.currentIndex()
      offset = self.presentationModel.rowCount() - index.row() - 1
      self.changeCurrentSlide(offset)

   def onSelected(self, obj):
      if self.mSelectedObject == obj:
         return
      self.mSelectedObject = obj

      if obj is not None:
        self.glWidget.manager.setHighlight(obj.getRootNode())
      else:
        self.glWidget.manager.setHighlight(None)

      if self.mFormatTab is not None:
         self.ribbonWidget.removeTab(self.ribbonWidget.indexOf(self.mFormatTab))

      if obj is not None:
         self.mFormatTab = QtGui.QWidget()
         hboxlayout = QtGui.QHBoxLayout(self.mFormatTab)
         hboxlayout.setSpacing(6)
         hboxlayout.setMargin(9)

         self.ribbonWidget.addTab(self.mFormatTab, "Format")
         env = environ.Environment()
         groups = env.ribbonMgr.mTabs.get(obj.element.name, [])
         for (title, klass, spacing) in groups:
            parts = klass.split('.')
            (mod, k) = '.'.join(parts[:-1]), parts[-1]
            if not sys.modules.has_key(mod):
               __import__(mod)
            kls = getattr(sys.modules[mod], k)
            gb = QtGui.QGroupBox()
            gb.setTitle(title)
            gb.setAlignment(QtCore.Qt.AlignHCenter)
            gb_layout = QtGui.QHBoxLayout(gb)
            gb_layout.setSpacing(1)
            gb_layout.setMargin(1)
            group = kls(self.mSelectedObject)
            gb_layout.addWidget(group)
            hboxlayout.addWidget(gb)

            if spacing is not None and spacing != '':
               spacing = int(spacing)
               sizePolicy = QtGui.QSizePolicy(QtGui.QSizePolicy.Expanding,
                                              QtGui.QSizePolicy.Minimum)
               sizePolicy.setHorizontalStretch(spacing)
               sizePolicy.setVerticalStretch(0)
               gb.setSizePolicy(sizePolicy)

         spacer_item = QtGui.QSpacerItem(0, 0, QtGui.QSizePolicy.Expanding,
                                         QtGui.QSizePolicy.Minimum)
         hboxlayout.addItem(spacer_item)

         # Show the format tab when we select a scene object.
         self.ribbonWidget.setCurrentWidget(self.mFormatTab)

         # Select material in the list view for the selected object.
         if isinstance(self.mSelectedObject, opensg.ShapeMaterialMixin):
            self.mMaterialView.setCurrentIndex(
               self.materialModel.indexByName(obj.draw_style_name))

      # Update the OpenSG scene.
      self.glWidget.updateGL()

   def onMouseReleased(self):
      """ Called when the user releases the mouse. """
      self.refreshCurrentThumbnail()

   def refreshCurrentThumbnail(self):
      idx = self.slideThumbnailList.currentIndex()

      # If there is a current selection and we have a presentation model.
      if idx.isValid() and self.presentationModel is not None:
         self.presentationModel.refreshThumbnail(idx, refreshScene = False)
         self.slideThumbnailList.update(idx)

   def cleanStateChanged(self, clean):
      can_save = not clean
      self.actionSave.setEnabled(can_save)
      self.actionSave_As.setEnabled(can_save)
      self.actionRevert.setEnabled(can_save)
      self.__updateWindowTitle()

   def __updateWindowTitle(self):
      title = ""
      if self.curDocument is not None:
         file_name = self.curDocument.getFileName()
         if file_name is None:
            leaf_name = "[No Name]"
         else:
            leaf_name = os.path.split(file_name)[1]

         title = leaf_name
         if not self.curDocument.document.getUndoStack().isClean():
            title += " +"
         title += " - "
      title += "vPresent"
      self.setWindowTitle(title)

   def onNewDocument(self):
      # Close any existing documents.
      self.onCloseDocument()

      undo_stack = QtGui.QUndoStack(self.undoGroup)

      # Create a builder that is given basic skeleton OpenDocument
      # presentation data to use for creating the Presentation object.
      builder = gui.fileio.presentation.PresentationBuilder(undo_stack, self)
      skeleton_dir = os.path.join(const.EXEC_DIR, 'data', 'presentation')

      try:
         document = gui.util.docmaker.createPresentationFromSkeleton(builder,
                                                                     skeleton_dir)

         env = environ.Environment()
         work_dir = opendocument.createWorkDir()
         self.curDocument = DocumentHolder(None, work_dir,
                                           opendocument.destroyWorkDir,
                                           document)
         env.workDir = work_dir
         env.curDocument = self.curDocument

         undo_stack.setActive(True)

         self.actionSave.setEnabled(True)
         self.__documentOpened()
      except Exception, ex:
         traceback.print_exc()
         err_msg = "Failed to create new presentation document:\n%s" % str(ex)
         QtGui.QMessageBox.critical(self, 'Document Creation Failure',
                                    err_msg, QtGui.QMessageBox.Close)

   def __buildFileFilter(self, docTypes):
      file_filters = []
      for t in docTypes:
         exts = ['*.%s' % e for e in t.getExtensions()]
         file_filters.append('%s (%s)' % (t.getName(), ' '.join(exts)))

      return ';;'.join(file_filters)

   def onOpenDocument(self):
      env = environ.Environment()
      file_name = \
         QtGui.QFileDialog.getOpenFileName(
            self, self.tr("Open vPresent Presentation"), env.fileStartDir,
            self.tr(self.__buildFileFilter(env.docTypes))
         )

      if not file_name.isEmpty():
         file_name = str(file_name)
         self.__doOpenDocument(file_name)

   def __doOpenDocument(self, fileName):
      # Close any existing documents.
      self.onCloseDocument()

      env = environ.Environment()
      env.fileStartDir = os.path.abspath(os.path.dirname(fileName))

      document = None

      backup_files = glob.glob(fileName + ',*.vptmp')
      junk_files = []

      # Find junk backup files and remove them from backup_files and from the
      # file system.
      for f in backup_files:
         if os.stat(f)[stat.ST_SIZE] == 0:
            junk_files.append(f)

      for f in junk_files:
         backup_files.remove(f)
         os.unlink(f)

      if len(backup_files) > 0:
         # Create a list of tuples where the first member of the tuple is
         # the modification time of the backup file and the second member
         # is the name of the backup file.
         backup_mtimes = []
         for b in backup_files:
            backup_mtimes.append((os.stat(b)[stat.ST_MTIME], b))

         # Sort the list of tuples. This will make the most recently
         # modified backup file the last item in the list.
         backup_mtimes.sort()

         src_mtime = os.stat(fileName)[stat.ST_MTIME]
         newest_backup_entry = backup_mtimes[-1]
         if newest_backup_entry[0] > src_mtime:
            msg = "Restore backup with modification time of %s?" % \
                     time.ctime(newest_backup_entry[0])
            answer = \
               QtGui.QMessageBox.question(
                  self, "Restore Backup", msg,
                  QtGui.QMessageBox.Yes | QtGui.QMessageBox.No
               )

            if answer == QtGui.QMessageBox.Yes:
               f = open(newest_backup_entry[1], 'r')
               document = pickle.load(f)
               f.close()

               self.unsavedSinceRestore = True

               # Remove all backup files.
               for be in backup_mtimes:
                  os.unlink(be[1])

               document.setParent(self)

               # Setting the parent of the document's undo stack prevents
               # a segmentation fault when exiting.
               undo_stack = document.getUndoStack()
               undo_stack.setParent(self.undoGroup)
               self.undoGroup.addStack(undo_stack)

      work_dir = opendocument.createWorkDir()

      # XXX: Let everyone know the working dir while building.
      env.workDir = work_dir

      if document is None:
         undo_stack = QtGui.QUndoStack(self.undoGroup)
         builder = gui.fileio.presentation.PresentationBuilder(undo_stack, self)
         reader_class = \
            env.getDocReaderClassForExt(os.path.splitext(fileName)[1])
         reader = reader_class(fileName, builder)

         try:
            reader.construct(work_dir)
            document = builder.getPresentation()
         except Exception, ex:
            opendocument.destroyWorkDir(work_dir)
            traceback.print_exc()

      if document is not None:
         self.curDocument = DocumentHolder(fileName, work_dir,
                                           opendocument.destroyWorkDir,
                                           document)
         env.curDocument = self.curDocument
         document.getUndoStack().setActive(True)
         document.applySettings(env)

         self.__documentOpened()

   def onSaveDocument(self):
      assert self.curDocument is not None

      try:
         # If the current document has a name, then overwrite the named file.
         if self.curDocument.getFileName():
            self.curDocument.save()
            # Update the undo stack to it's new clean state.
            self.curDocument.document.getUndoStack().setClean()

         # Otherwise, we need to give the document a name before it can be
         # saved.
         else:
            self.onSaveDocumentAs()

         self.__documentSaved()
      except Exception, ex:
         traceback.print_exc()

   def onSaveDocumentAs(self):
      assert self.curDocument is not None

      env = environ.Environment()
      file_name = \
         QtGui.QFileDialog.getSaveFileName(
            self, self.tr("Save vPresent Presentation As ..."),
            env.fileStartDir, self.tr(self.__buildFileFilter(env.docTypes))
         )

      if file_name:
         file_name = str(file_name)
         env.fileStartDir = os.path.abspath(os.path.dirname(file_name))

         # When the user does not specify an extention, use a default.
         if '' == os.path.splitext(file_name)[1]:
            file_name += '.odp'

         self.curDocument.save(file_name)
         self.__documentSaved()

   def onRevert(self):
      if self.curDocument is not None:
         undo_stack = self.curDocument.document.getUndoStack()
         undo_stack.setIndex(undo_stack.cleanIndex())

   def onCloseDocument(self):
      if self.curDocument is not None:
         if self.curDocument.hasUnsavedChanges or self.unsavedSinceRestore:
            msg = "Current document has unsaved changes. Do you want to save?"
            answer = \
               QtGui.QMessageBox.question(
                  self, "Unsaved Changes", msg,
                  QtGui.QMessageBox.Save | QtGui.QMessageBox.Discard,
                  QtGui.QMessageBox.Save)

            if answer == QtGui.QMessageBox.Save:
               if self.curDocument.getFileName():
                  self.curDocument.save()
               # Otherwise, we need to give the document a name before it can be
               # saved.
               else:
                  # XXX: If the user cancels SaveAs dialog, all changes are lost.
                  self.onSaveDocumentAs()

         self.curDocument.close()
         self.curDocument = None
         env = environ.Environment()
         env.curDocument = None

      self.__documentClosed()

   def __documentOpened(self):
      self.actionSave_As.setEnabled(True)
      self.actionClose.setEnabled(True)

      self.__updateWindowTitle()

      # Call helper to update buttons.
      # NOTE: Do this before everything else so that other code can
      #       change the button state.
      self.__documentLoadedChanged(True)

      # If we opened an existing file, add it to the recent list.
      file_name = self.curDocument.getFileName()
      if file_name is not None:
         self.__addRecentFile(file_name)

      # Create presentation model.
      self.presentationModel = PresentationModel(self.curDocument.document)
      self.slideThumbnailList.setModel(self.presentationModel)
      self.presentationModel.refreshAllThumbnails()
      self.slideThumbnailList.setDragEnabled(True)
      self.slideThumbnailList.setAcceptDrops(True)
      self.slideThumbnailList.setDropIndicatorShown(True)
      #self.slideThumbnailList.setDragDropMode(QtGui.QAbstractItemView.InternalMove)

      # Connect new selection model
      QtCore.QObject.connect(self.slideThumbnailList.selectionModel(),
         QtCore.SIGNAL("selectionChanged(QItemSelection,QItemSelection)"), self.onSlideSelected)

      QtCore.QObject.connect(self.presentationModel,
         QtCore.SIGNAL("refreshGLWidget"), self.onRefreshGLWidget)

      self.connect(self.presentationModel,
                   QtCore.SIGNAL("rowsInserted(QModelIndex,int,int)"),
                   self.onSlideInserted)
      self.connect(self.presentationModel,
                   QtCore.SIGNAL("rowsRemoved(QModelIndex,int,int)"),
                   self.onSlideRemoved)

      # Setup texture model.
      self.materialModel = MaterialModel(self.curDocument.document, self.curDocument.workDir)
      self.mMaterialView.setModel(self.materialModel)
      self.mMaterialView.setDragEnabled(True)
      self.connect(self.materialModel,
                   QtCore.SIGNAL("rowsInserted(QModelIndex,int,int)"),
                   self.onMaterialInserted)

      # Create the background model.
      self.backgroundModel = background.BackgroundModel(self.curDocument.document)
      self.backgroundList.setModel(self.backgroundModel)

      # Connect new selection model.
      QtCore.QObject.connect(self.backgroundList.selectionModel(),
         QtCore.SIGNAL("currentChanged(QModelIndex, QModelIndex)"), self.onBackgroundChanged)

      # Update the background buttons to the correct enabled state.
      self.__updateBackgroundButtons()

      # Select the first slide.
      idx = self.presentationModel.index(0)
      self.slideThumbnailList.selectionModel().setCurrentIndex(idx, QtGui.QItemSelectionModel.ClearAndSelect)

   def __documentSaved(self):
      self.actionSave.setEnabled(False)
      self.unsavedSinceRestore = False
      # Update the undo stack to it's new clean state.
      self.curDocument.document.getUndoStack().setClean()

   def __documentClosed(self):
      self.actionSave.setEnabled(False)
      self.actionSave_As.setEnabled(False)
      self.actionClose.setEnabled(False)
      self.__updateWindowTitle()


      if self.presentationModel is not None:
         self.disconnect(self.presentationModel,
                         QtCore.SIGNAL("refreshGLWidget"),
                         self.onRefreshGLWidget)
         self.disconnect(self.presentationModel,
                         QtCore.SIGNAL("rowsInserted(QModelIndex,int,int)"),
                         self.onSlideInserted)
         self.disconnect(self.presentationModel,
                         QtCore.SIGNAL("rowsRemoved(QModelIndex,int,int)"),
                         self.onSlideRemoved)
         self.disconnect(self.materialModel,
                         QtCore.SIGNAL("rowsInserted(QModelIndex,int,int)"),
                         self.onMaterialInserted)

      # If selection model already exists then disconnect signal
      if self.slideThumbnailList.selectionModel() is not None:
         QtCore.QObject.disconnect(self.slideThumbnailList.selectionModel(),
            QtCore.SIGNAL("selectionChanged(QItemSelection,QItemSelection)"), self.onSlideSelected)

      self.presentationModel = None
      self.slideThumbnailList.setModel(self.presentationModel)
      
      self.materialModel = None
      self.mMaterialView.setModel(self.materialModel)

      # Update the OpenSG scene.
      self.setCurrentPage(None)

      # Update object selection which results in updating the ribbon.
      # (remove the format tab if something was selected)
      self.onSelected(None)
      # Reset the current tab to be the home tab.
      self.ribbonWidget.setCurrentWidget(self.homeTab)

      # If selection model already exists then disconnect signal
      if self.backgroundList.selectionModel() is not None:
         QtCore.QObject.disconnect(self.backgroundList.selectionModel(),
            QtCore.SIGNAL("currentChanged(QModelIndex, QModelIndex)"), self.onBackgroundChanged)

      # Clear the background model.
      self.backgroundModel = None
      self.backgroundList.setModel(self.backgroundModel)

      # Call helper to update buttons.
      # NOTE: Do this after everything else so that we ensure the
      #       buttons get the correct state.
      self.__documentLoadedChanged(False)

   def __documentLoadedChanged(self, documentActive):
      # Main tab widgets.
      main_widgets = [self.actionAddSlide, self.actionRemoveSlide,
                      self.actionClusterView, self.actionPlay, self.actionStop,
                      self.actionClusterView, self.actionNext,
                      self.actionPrevious, self.actionStop,
                      self.actionLastSlide, self.actionFirstSlide]

      # Material buttons.
      material_actions = [self.actionAddMaterial,]

      for w in main_widgets + material_actions:
         w.setEnabled(documentActive)

      # Design buttons.
      self.addBackgroundBtn.setEnabled(documentActive)
      self.actionEditBackground.setEnabled(False)
      self.actionRemoveBackground.setEnabled(False)


   def __currentPageChanged(self, pageActive):
      # Insert buttons.
      insert_widgets = [self.actionInsertModel, self.actionAddBillboard,
                        self.actionAddPanorama, self.actionAddFisheye,
                        self.insertShapeButton]

      for w in insert_widgets:
         w.setEnabled(pageActive)

   def __getValidModelExtensions(self):
      suffix_list = osg.SceneFileHandler.the().getSuffixList()
      return "OpenSG Model (" + " ".join(["*.%s"%sf for sf in suffix_list]) + ")"

   def setCurrentPage(self, newPage):
      """ Called when the active page has changed. """
      bg_root = self.glWidget.manager.getRotationOnlyRoot()

      if self.mCurrentPage is not None:
         # Make sure to call setSceneRoot() first since it initializes
         # everything.
         self.glWidget.manager.setSceneRoot(None)
         current_background_root = self.mCurrentPage.getBackgroundRoot()
         bg_root.subChild(current_background_root)

      self.mCurrentPage = newPage

      if self.mCurrentPage is not None:
         self.mCurrentPage.updateBackgroundSize(self._curFar)

         # Ensure that the page has been loaded.
         self.mCurrentPage._update()
         new_root = newPage.getRootNode()
         self.glWidget.manager.setSceneRoot(new_root)

         new_background_root = self.mCurrentPage.getBackgroundRoot()
         bg_root.addChild(new_background_root)

         self.backgroundList.setEnabled(True)
         # Select the background for the current page.
         if self.backgroundModel is not None:
            idx = self.backgroundModel.indexByName(self.mCurrentPage.draw_style_name)
            self.backgroundList.selectionModel().blockSignals(True)
            self.backgroundList.setCurrentIndex(idx)
            self.backgroundList.selectionModel().blockSignals(False)
      else:
         self.backgroundList.setEnabled(False)
         if self.backgroundModel is not None:
            # Select the special None page style.
            idx = self.backgroundModel.index(0)
            self.backgroundList.selectionModel().blockSignals(True)
            self.backgroundList.setCurrentIndex(idx)
            self.backgroundList.selectionModel().blockSignals(False)

      # Update the background buttons to the correct enabled state.
      self.__updateBackgroundButtons()

      self.glWidgetFilter.setPage(self.mCurrentPage)
      # Clear the highlight.
      self.glWidget.manager.setHighlight(None)

      # Update the OpenSG scene.
      if self.mCurrentPage is not None:
         mgr = self.glWidget.manager
         if vPresent.MODE_EDIT == self.mode:
            self.onShowAll()
         else:
            self.onResetView()

      # Update button state depending on if there is a page selected.
      self.__currentPageChanged(self.mCurrentPage is not None)

      self.glWidget.updateGL()
      self.refreshCurrentThumbnail()

   def closeEvent(self, event):
      self.glWidget.manager.shutdown()

      if self.curDocument is not None:
         if self.curDocument.hasUnsavedChanges or self.unsavedSinceRestore:
            msg = "Current document has unsaved changes. Do you want to save?"
            answer = \
               QtGui.QMessageBox.question(
                  self, "Unsaved Changes", msg,
                  QtGui.QMessageBox.Save | QtGui.QMessageBox.Discard | QtGui.QMessageBox.Cancel,
                  QtGui.QMessageBox.Cancel
               )

            if answer == QtGui.QMessageBox.Cancel:
               event.ignore()
               return
            if answer == QtGui.QMessageBox.Save:
               if self.curDocument.getFileName():
                  self.curDocument.save()
               # Otherwise, we need to give the document a name before it can be
               # saved.
               else:
                  # XXX: If the user cancels SaveAs dialog, all changes are lost.
                  self.onSaveDocumentAs()
         # Close the document so that we clean up backups.
         self.curDocument.close()

      # Accept the event so that we actaully exit.
      event.accept()

      rect = self.geometry()
      env = environ.Environment()

      try:
         env.settings['gui_layout/width']  = rect.width()
         env.settings['gui_layout/height'] = rect.height()
         env.settings['gui_layout/x']      = rect.x()
         env.settings['gui_layout/y']      = rect.y()

         # We only save the user-specific settings.
         env.settings.user().save()
      except:
         traceback.print_exc()

   def onBackgroundContextMenu(self, point):
      """ Create a pop-up menu listing all valid operations for selection. """
      # Create a menu
      menu = QtGui.QMenu("Format", self)
      menu.addAction(self.actionCreateCubicBackground)
      menu.addAction(self.actionCreateMercatorBackground)

      # Get the currently selected node.
      background = self.getCurrentBackground()
      if background is not None:
         menu.addAction(self.actionEditBackground)
         menu.addSeparator()
         menu.addAction(self.actionRemoveBackground)

      # Show the context menu.
      menu.exec_(self.backgroundList.mapToGlobal(point))

   def onEditBackground(self):
      page_style = self.getCurrentBackground()
      if page_style is not None:
         print "onEditBackground %s %s" % (page_style, page_style.style_name)
         dialog = gui.background.editdialog.EditDialog(self.curDocument,
                                                       page_style, self)
         self.connect(dialog, QtCore.SIGNAL("finished(int)"), self.onEditBackgroundFinished)
         dialog.show()

   def onEditBackgroundFinished(self, result):
      dialog = self.sender()
      self.disconnect(dialog, QtCore.SIGNAL("accepted()"), self.onEditBackgroundFinished)
      if QtGui.QDialog.Accepted == result:
         undo_stack = self.curDocument.document.getUndoStack()
         dialog.undoStackHelper.createMacro(undo_stack, "Background Change")

   def onRemoveBackground(self):
      """ Called when the user wants to remove the selected background. """
      page_style = self.getCurrentBackground()
      if page_style is not None:
         answer = \
            QtGui.QMessageBox.question(
               self, "Remove Background", "Are you sure that you want to remove the background.",
               QtGui.QMessageBox.Yes | QtGui.QMessageBox.Cancel,
               QtGui.QMessageBox.Cancel
            )

         if answer == QtGui.QMessageBox.Yes:
            # Set the current background to None before removing it.
            # XXX: This will cause two different UndoCommands, Assign None, Remove old
            new_idx = self.backgroundModel.index(0)
            self.backgroundList.setCurrentIndex(new_idx)

            # XXX: Does not update the OpenSG
            remove_page_style = background.RemovePageStyle(self.backgroundModel, page_style)
            undo_stack = self.curDocument.document.getUndoStack()
            undo_stack.push(remove_page_style)

            # Update the current page to make sure it has the correct background.
            page = self.getCurrentPage()
            if page is not None:
               page._dirty = True
               page._update()
               # Update the OpenSG widget.
               env = environ.Environment()
               env.updateGL()

   def onEditPrefs(self):
      prefsui = prefs.PrefsEditor(environ.Environment(), self)
      prefsui.setModal(True)
      prefsui.show()
      prefsui.exec_()

   def onLicense(self):
      try:
         f = open(os.path.join(const.EXEC_DIR, 'LICENSE.html'), 'r')
         input = f.readlines()
         lic_html = '\n'.join(input)
         f.close()

         dialog = QtGui.QDialog(self)
         lic_ui = licenseui.Ui_Dialog()
         lic_ui.setupUi(dialog)
         lic_ui.textBrowser.setHtml(lic_html)
         dialog.exec_()
      except IOError:
         error_msg = \
            "You should have received a copy of the GNU General Public " \
            "License with this software, but it could not be found. This is " \
            "in violation of the terms of the redistribution of this " \
            "software!"
         QtGui.QMessageBox.critical(self, "License Not Found", error_msg,
                                    QtGui.QMessageBox.Default | \
                                       QtGui.QMessageBox.Escape,
                                    QtGui.QMessageBox.NoButton,
                                    QtGui.QMessageBox.NoButton)

   def onAbout(self):
      dialog = QtGui.QDialog(self)
      about_ui = aboutui.Ui_Dialog()
      about_ui.setupUi(dialog)
      dialog.exec_()

   def onToggleOpenSGStats(self, state):
      if self.glWidget:
         self.glWidget.manager.setStatistics(state == QtCore.Qt.Checked)
         self.glWidget.updateGL()

   def onSlideSelected(self, selected, deselected):
      new_page = None
      if not selected.isEmpty():
         idx = self.slideThumbnailList.currentIndex()
         row = idx.row()
         assert row >= 0
         new_page = self.presentationModel.data(idx, QtCore.Qt.UserRole)

      self.setCurrentPage(new_page)

   def onRefreshGLWidget(self):
      """ Called when someone wants to force the scene to be re-drawn. """
      self.glWidget.updateGL()

   def onStartPresentation(self):
      self.actionPlay.setEnabled(False)
      self.onSelected(None)
      self.mode = vPresent.MODE_PLAY
      self.onResetView()

      self._displayWindow = QtGui.QDialog(self)
      self.connect(self._displayWindow, QtCore.SIGNAL("rejected()"),
                   self.onDisplayClosed)

      vbox_layout = QtGui.QVBoxLayout(self._displayWindow)
      vbox_layout.setSpacing(0)
      vbox_layout.setMargin(0)
      self.glWidget.setParent(self._displayWindow)
      vbox_layout.addWidget(self.glWidget)

      self._displayWindow.showFullScreen()
      self.glWidget.updateGL()

   def endPresentation(self):
      if self._displayWindow is not None:
         self._displayWindow.reject()
      else:
         self.onStopPresentation()

   def onDisplayClosed(self):
      '''
      This is a slot for the rejected() signal emitted by the full-screen
      display window shown when a presentation is being shown.
      '''
      self.disconnect(self._displayWindow, QtCore.SIGNAL("rejected()"),
                      self.onDisplayClosed)

      self.glWidget.setParent(self.previewFrame)
      self.previewFrame.layout().addWidget(self.glWidget)

      self._displayWindow = None

      self.onStopPresentation()

   def onStopPresentation(self):
      self.actionPlay.setEnabled(True)
      self.mode = vPresent.MODE_EDIT
      self.onShowAll()

   def onPreviousSlide(self):
      self.changeCurrentSlide(-1)

   def onNextSlide(self):
      self.changeCurrentSlide(1)

   def onFirstSlide(self):
      self.changeToFirstSlide()

   def onLastSlide(self):
      self.changeToLastSlide()

   def onShowAll(self):
      self.glWidget.manager.refreshScene()

   def onResetView(self):
      self.glWidget.manager.resetView()

   def onConfigureServers(self, toggled):
      '''
      Depending on the value of toggled, the render servers are either
      configured or "deconfigured". The configuration process involves sending
      the necessary information in our settings database to each of the render
      servers.
      '''
      # Disconnect from all cluster nodes.
      if not toggled:
         # If the scene manager has a communicator, then this results in the
         # disconnection from the render servers. If it does not have a
         # communicator, then this has no effect.
         self.glWidget.manager.shutdown()

         #self.actionClusterView.setEnabled(True)
         #self.actionClusterView.setChecked(False)
 
         # Don't need because we are already rendering.
         #self.glWidget.manager.cancelRender()
      else:
         comm = gui.cluster.opensg.Communicator(const.FIRST_FC)

         env = environ.Environment()
         settings = env.getSettings()
         protocol  = settings['network/protocol']
         comm_type = protocol['type']

         comm.setConnectionType(comm_type)

         if 'Multicast' == comm_type:
            service_addr = protocol.getChild('address')
            service_port = protocol.getChild('port')
            comm.setServiceAddress("%s:%s" % (service_addr, service_port))
         else:
            service_addr = None
            service_port = None

         try:
            self.glWidget.manager.configureServers(comm, env.getCluster(),
                                                   comm_type, service_addr,
                                                   service_port)
         except Exception, ex:
            self.actionClusterView.setChecked(False)

            trace_entries = traceback.extract_tb(sys.exc_traceback)
            ex_info       = ''.join(traceback.format_list(trace_entries))
            err_msg       = "Failed to configure render servers:\n%s\n%s" % \
                               (str(ex), ex_info)
            QtGui.QMessageBox.critical(self,
                                       'Render Server Configuration Failure',
                                       err_msg, QtGui.QMessageBox.Close)
            # If configuring failed, then don't try to connect using OpenSG
            # to render.
            return

         # If the scene manager has a communicator, then this causes the scene
         # graph state to be sent to the configured servers for rendering. If it
         # does not have a communicator, then this has no effect.
         try:
            self.glWidget.manager.connectToServers()
            self.glWidget.updateGL()
         except Exception, ex:
            self.actionClusterView.setChecked(False)

            trace_entries = traceback.extract_tb(sys.exc_traceback)
            ex_info       = ''.join(traceback.format_list(trace_entries))
            err_msg       = "Failed to connect to render servers:\n%s\n%s" % \
                               (str(ex), ex_info)
            QtGui.QMessageBox.critical(self, 'Render Server Connection Failure',
                                       err_msg, QtGui.QMessageBox.Close)
            self.glWidget.manager.disconnectFromServers()

   def onAddSlide(self):
      undo_stack = self.curDocument.document.getUndoStack()
      add_slide = InsertSlide(self.curDocument.document, self.presentationModel, row=None)
      undo_stack.push(add_slide)
      # Start editing the slide.
      # NOTE: This only works because of onSlideInserted
      idx = self.slideThumbnailList.currentIndex()
      self.slideThumbnailList.edit(idx)

   def onRemoveSlide(self):
      idx = self.slideThumbnailList.currentIndex()
      row = idx.row()
      assert row >= 0
      page = self.presentationModel.data(idx, QtCore.Qt.UserRole)
      undo_stack = self.curDocument.document.getUndoStack()
      remove_slide = RemoveSlide(self.curDocument.document, self.presentationModel, row, page)
      undo_stack.push(remove_slide)
 
   def onSlideInserted(self, idx, start, end):
      assert start == end
      assert self.presentationModel is not None
      idx = self.presentationModel.index(start)
      self.slideThumbnailList.selectionModel().setCurrentIndex(idx, QtGui.QItemSelectionModel.ClearAndSelect)

   def onSlideRemoved(self, idx, start, end):
      assert start == end
      assert self.presentationModel is not None
      if start >= self.presentationModel.rowCount():
         start = self.presentationModel.rowCount() - 1

      if start >= 0:
         idx = self.presentationModel.index(start)
         self.slideThumbnailList.selectionModel().setCurrentIndex(idx, QtGui.QItemSelectionModel.ClearAndSelect)

   def onMaterialInserted(self, idx, start, end):
      assert start == end
      assert self.materialModel is not None
      idx = self.materialModel.index(start)
      self.mMaterialView.selectionModel().setCurrentIndex(idx, QtGui.QItemSelectionModel.ClearAndSelect)

   def onInsertModel(self):
      idx = self.slideThumbnailList.currentIndex()
      row = idx.row()
      if row < 0:
         QtGui.QMessageBox.critical(self, 'No Slide Selected',
                                    "No slide is selected.", QtGui.QMessageBox.Close)
         return

      env = environ.Environment()
      model_suffix_str = self.__getValidModelExtensions()
      file_name = \
         QtGui.QFileDialog.getOpenFileName(
            self, self.tr("Add OpenSG Model"), env.fileStartDir, model_suffix_str
         )

      if not file_name.isEmpty():
         # Convert the filename from a QString into a python str.
         file_name = str(file_name)
         # Keep around the last directory location.
         env.fileStartDir = os.path.abspath(os.path.dirname(file_name))

         if os.path.exists(file_name):
            # Get the display name for the material from the filename.
            display_name = os.path.basename(file_name)

            # Import the new model into the presentation.
            # TODO: Find a way to remove resources. Also models with the same name
            #       can't currently exist.
            href = env.curDocument.importResource(file_name, subDir='models')

            page = self.presentationModel.data(idx, QtCore.Qt.UserRole)
            add_model = AddModel(self.presentationModel, page, href)
            undo_stack = self.curDocument.document.getUndoStack()
            undo_stack.push(add_model)
            self.onSelected(add_model.mModel)
            self.ribbonWidget.setCurrentWidget(self.mFormatTab)

   def onInsertShape(self, shapeType):
      # Find the page that we want to add the slide to.
      idx = self.slideThumbnailList.currentIndex()
      assert idx.isValid()
      page = self.presentationModel.data(idx, QtCore.Qt.UserRole)
      assert page is not None

      # Create a InsertShape command and push it onto the undo stack.
      insert_shape = InsertShape(shapeType, self.presentationModel, page)
      undo_stack = self.curDocument.document.getUndoStack()
      undo_stack.push(insert_shape)
      # Select new shape.
      # NOTE: We do not do this in the undo/redo command.
      # TODO: Decide if this is the behavior that we want.
      self.onSelected(insert_shape.mShape)
      self.ribbonWidget.setCurrentWidget(self.mFormatTab)
      self.onShowAll()

   def onAddFisheye(self):
      # Find the page that we want to add the slide to.
      idx = self.slideThumbnailList.currentIndex()
      assert idx.isValid()
      page = self.presentationModel.data(idx, QtCore.Qt.UserRole)
      assert page is not None

      # Create a AddPanorama command and push it onto the undo stack.
      add_fisheye = AddFisheye(self.presentationModel, page)
      undo_stack = self.curDocument.document.getUndoStack()
      undo_stack.push(add_fisheye)
      # Select new fisheye.
      # NOTE: We do not do this in the undo/redo command.
      # TODO: Decide it this is the behavior that we want.
      self.onSelected(add_fisheye.mFisheye)
      self.ribbonWidget.setCurrentWidget(self.mFormatTab)

   def onAddPanorama(self):
      # Find the page that we want to add the slide to.
      idx = self.slideThumbnailList.currentIndex()
      assert idx.isValid()
      page = self.presentationModel.data(idx, QtCore.Qt.UserRole)
      assert page is not None

      # Create a AddPanorama command and push it onto the undo stack.
      add_panorama = AddPanorama(self.presentationModel, page)
      undo_stack = self.curDocument.document.getUndoStack()
      undo_stack.push(add_panorama)
      # Select new panorama.
      # NOTE: We do not do this in the undo/redo command.
      # TODO: Decide it this is the behavior that we want.
      self.onSelected(add_panorama.mPanorama)
      self.ribbonWidget.setCurrentWidget(self.mFormatTab)

   def onAddBillboard(self):
      # Find the page that we want to add the slide to.
      idx = self.slideThumbnailList.currentIndex()
      assert idx.isValid()
      page = self.presentationModel.data(idx, QtCore.Qt.UserRole)
      assert page is not None

      # Create a AddPanorama command and push it onto the undo stack.
      add_billboard = AddBillboard(self.presentationModel, page)
      undo_stack = self.curDocument.document.getUndoStack()
      undo_stack.push(add_billboard)
      # Select new panorama.
      # NOTE: We do not do this in the undo/redo command.
      # TODO: Decide it this is the behavior that we want.
      self.onSelected(add_billboard.mBillboard)
      self.ribbonWidget.setCurrentWidget(self.mFormatTab)

   def onAddMaterial(self):
      env = environ.Environment()
      image_suffix_str = env.getValidImageExtensions()
      file_names = \
         QtGui.QFileDialog.getOpenFileNames(
            self, self.tr("Add Material"), env.fileStartDir, image_suffix_str
         )

      row = -1

      for file_name in file_names:
         # Convert the filename from a QString into a python str.
         file_name = str(file_name)
         # Keep around the last directory location.
         env.fileStartDir = os.path.abspath(os.path.dirname(file_name))

         # Get the display name for the material from the filename.
         display_name = os.path.basename(file_name)

         # Import the new texture into the presentation.
         # TODO: Find a way to remove resources. Also textures with the same name
         #       can't currently exist.
         href = env.curDocument.importResource(file_name)

         # Create the new material.
         undo_stack = self.curDocument.document.getUndoStack()
         add_material = InsertMaterial(self.curDocument.document,
            self.materialModel, href, display_name, row=None)
         undo_stack.push(add_material)

      # Start editing the material.
      # NOTE: This only works because of onMaterialInserted
      idx = self.mMaterialView.currentIndex()
      self.mMaterialView.edit(idx)

   def onBackgroundChanged(self, current, previous):
      if current.isValid():
         row = current.row()
         assert row >= 0
         new_page_style = self.backgroundModel.data(current, QtCore.Qt.UserRole)
         new_style_name = None
         if new_page_style is not None:
            new_style_name = new_page_style.style_name

         # Get the current page.
         page = self.getCurrentPage()
         # Assign the page style to the current page.
         assign_page_style = AssignPageStyle(self.curDocument.document, page,
                                             new_style_name)
         self.curDocument.document.getUndoStack().push(assign_page_style)

      # Update the background buttons to the correct enabled state.
      self.__updateBackgroundButtons()

   def __updateBackgroundButtons(self):
      current = self.backgroundList.currentIndex()
      # Disable edit and remove if no background is selected, or
      # the None page style is selected.
      enable_background_edit = True
      if not current.isValid() or current.row() == 0:
         enable_background_edit = False

      # Update the background buttons depending on the current background state.
      widgets = [self.actionEditBackground, self.actionRemoveBackground]
      for w in widgets:
         w.setEnabled(enable_background_edit)

   def onAddBackground(self, type):
      doc = self.curDocument.document
      name = doc.generatePageStyleName()

      # XXX: Do we need style_display_name?
      page_style = opendocument.createNode('style:style', style_name=name,
                                                style_family='page3d')
      page3d_props = opendocument.createNode('vpresent:page3d-properties')

      if background.AddPageStyle.CUBIC == type:
         spherebg = opendocument.createNode('vpresent:sphere-background', vpresent_type='cubic')
      else:
         spherebg = opendocument.createNode('vpresent:sphere-background', vpresent_type='mercator')

      # Set all background spheres to have a radius of 1.0. This is done so
      # that we can scale the backgrounds uniformly to accomodate dynamically
      # changing scene sizes.
      sphere = opendocument.createNode('dr3d:sphere', dr3d_size = 1.0)
      spherebg.addChild(sphere)
      page_style.addChild(page3d_props)
      page3d_props.addChild(spherebg)

      undo_stack = doc.getUndoStack()
      add_page_style = background.AddPageStyle(doc, self.backgroundModel, page_style)
      undo_stack.push(add_page_style)

      # Scroll to the new background.
      new_idx = self.backgroundModel.index(self.backgroundModel.rowCount()-1)
      self.backgroundList.setCurrentIndex(new_idx)
      self.backgroundList.scrollTo(new_idx)

      # Start editing the background.
      self.onEditBackground()

SLIDE_CREATE_ID    = 0
MATERIAL_CREATE_ID = 1

class InsertSlide(QtGui.QUndoCommand):
   def __init__(self, document, presModel, row=None, parent=None):
      if row is None:
         desc = 'Add Slide'
      else:
         desc = 'Insert Slide'
      QtGui.QUndoCommand.__init__(self, desc, parent)

      self.mDocument = document
      self.mPresentationModel = presModel
      self.mRow = row
      if self.mRow is None:
         self.mRow = self.mPresentationModel.rowCount()
      self.mNewPage = opendocument.createNode("draw:page", draw_name="New Slide", draw_master_page_name="Default")
      self.mNewPage.addChild(opendocument.createNode("dr3d:scene"))
      self.mMerged = None

   def id(self):
      return SLIDE_CREATE_ID

   def mergeWith(self, command):
      '''
      Determines if the given command can be merged with this command. This
      is allows if the command is a ChangeSlideName instance. In that case, we
      treat the two-step process of creating a slide and setting its initial
      name as a single command.
      '''
      result = False

      if command.id() == self.id():
         # We only merge the given command if we do not already have a merged
         # command.
         if self.mMerged is None and command.__class__ == ChangeSlideName:
            self.mMerged = command
            result = True

      return result

   def redo(self):
      self.mPresentationModel.insertPage(self.mRow, self.mNewPage)

      if self.mMerged is not None:
         self.mMerged.redo()

   def undo(self):
      if self.mMerged is not None:
         self.mMerged.undo()

      self.mPresentationModel.removePage(self.mRow)

class RemoveSlide(QtGui.QUndoCommand):
   def __init__(self, document, presModel, row, oldPage, parent=None):
      QtGui.QUndoCommand.__init__(self, 'Remove Slide', parent)
      self.mDocument = document
      self.mPresentationModel = presModel
      self.mRow = row
      self.mOldPage = oldPage

   def redo(self):
      self.mPresentationModel.removePage(self.mRow)

   def undo(self):
      self.mPresentationModel.insertPage(self.mRow, self.mOldPage)

class MoveSlide(QtGui.QUndoCommand):
   def __init__(self, presModel, page, oldRow, newRow, parent=None):
      QtGui.QUndoCommand.__init__(self, 'Move Slide', parent)
      self.mPresentationModel = presModel
      self.mPage = page
      self.mOldRow = oldRow
      self.mNewRow = newRow

   def redo(self):
      self.mPresentationModel.removePage(self.mOldRow)
      self.mPresentationModel.insertPage(self.mNewRow, self.mPage)

   def undo(self):
      self.mPresentationModel.removePage(self.mNewRow)
      self.mPresentationModel.insertPage(self.mOldRow, self.mPage)

class ChangeSlideName(QtGui.QUndoCommand):
   def __init__(self, presModel, page, newName, parent = None):
      QtGui.QUndoCommand.__init__(self, 'Change Slide Name', parent)
      self.mPresentationModel = presModel
      self.mPage    = page
      self.mNewName = newName
      self.mOldName = page.draw_name

   def id(self):
      # This returns SLIDE_CREATE_ID because setting the initial name for a
      # newly created slide is treated as part of the creation process. See
      # InsertSlide for more information.
      return SLIDE_CREATE_ID

   def redo(self):
      self.mPage.draw_name = self.mNewName
      self.mPresentationModel.pageChanged(self.mPage)

   def undo(self):
      self.mPage.draw_name = self.mOldName
      self.mPresentationModel.pageChanged(self.mPage)

class DeleteSceneObject(QtGui.QUndoCommand):
   def __init__(self, presModel, page, sceneObj, parent=None):
      QtGui.QUndoCommand.__init__(self, 'Delete Scene Object', parent)
      self.mPresentationModel = presModel
      self.mPage = page
      self.mSceneObject = sceneObj
      self.mScene = self.mPage.find('./dr3d:scene')
      assert self.mScene is not None
      assert self.mSceneObject in self.mScene._children
      self.mOldIdx = self.mScene._children.index(self.mSceneObject)

   def redo(self):
      self.mScene.subChild(self.mSceneObject)
      self.mPresentationModel.pageChanged(self.mPage, refreshView=True)

   def undo(self):
      self.mScene.insertChild(self.mOldIdx, self.mSceneObject)
      self.mPresentationModel.pageChanged(self.mPage, refreshView=True)

class AddModel(QtGui.QUndoCommand):
   def __init__(self, presModel, page, href, parent=None):
      QtGui.QUndoCommand.__init__(self, 'Add Model', parent)
      self.mPresentationModel = presModel
      self.mPage = page
      self.mFilename = href
      self.mScene = self.mPage.find('./dr3d:scene')
      if self.mScene is None:
         self.mScene = self.mPage.addChild(opendocument.createNode('dr3d:scene'))
      self.mModel = opendocument.createNode('vpresent:model',
                                            vpresent_filename=self.mFilename)

   def redo(self):
      self.mScene.addChild(self.mModel)
      self.mPresentationModel.pageChanged(self.mPage, refreshView=True)

   def undo(self):
      self.mScene.subChild(self.mModel)
      self.mPresentationModel.pageChanged(self.mPage, refreshView=True)

class InsertShape(QtGui.QUndoCommand):
   """ Inserts a shape into a given presentation page. """
   SPHERE     = 0
   BOX        = 1
   CYLINDER   = 2
   NUM_SHAPES = 3

   def __init__(self, shapeType, presModel, page, parent=None):
      """ Construct the OpenDocument data structure for the given shape
          and store it to be inserted later.

          @param shapeType Type of shape we are inserting.
          @presModel PresentationModel that we need to signal after we make
                     a change to our page.
          @page OpenDocument page that we are adding a shape to.
      """
      QtGui.QUndoCommand.__init__(self, 'Insert Shape', parent)

      assert shapeType < InsertShape.NUM_SHAPES
      self.mPresentationModel = presModel
      self.mPage = page
      self.mScene = self.mPage.find('./dr3d:scene')
      if self.mScene is None:
         self.mScene = self.mPage.addChild(opendocument.createNode('dr3d:scene'))


      if InsertShape.SPHERE == shapeType:
         self.setText("Insert Sphere")
         self.mShape = opendocument.createNode('dr3d:sphere',
                                               dr3d_size=1.0,
                                               vpresent_latres=50,
                                               vpresent_lonres=50)
      elif InsertShape.BOX == shapeType:
         self.setText("Insert Box")
         self.mShape = opendocument.createNode('vpresent:box',
                                               vpresent_width=2.0,
                                               vpresent_height=2.0,
                                               vpresent_depth=2.0,
                                               vpresent_width_res=1,
                                               vpresent_height_res=1,
                                               vpresent_depth_res=1)
      else:
         self.setText("Insert Cylinder")
         self.mShape = opendocument.createNode('vpresent:cylinder',
                                               vpresent_height=5.0,
                                               vpresent_radius=1.0,
                                               vpresent_num_sides=8,
                                               vpresent_show_sides=True,
                                               vpresent_show_top=True,
                                               vpresent_show_bottom=True)
      # Build shape geometry.
      self.mShape._update()

   def redo(self):
      self.mScene.addChild(self.mShape)
      self.mPresentationModel.pageChanged(self.mPage, refreshView=True)

   def undo(self):
      self.mScene.subChild(self.mShape)
      self.mPresentationModel.pageChanged(self.mPage, refreshView=True)


class AddPanorama(QtGui.QUndoCommand):
   def __init__(self, presModel, page, parent=None):
      QtGui.QUndoCommand.__init__(self, 'Add Panorama', parent)

      self.mPresentationModel = presModel
      self.mPage = page
      self.mScene = self.mPage.find('./dr3d:scene')
      if self.mScene is None:
         self.mScene = self.mPage.addChild(opendocument.createNode('dr3d:scene'))


      self.mPanorama =  \
         opendocument.createNode('vpresent:cylinder-panorama',
                                 vpresent_height=50.0,
                                 vpresent_radius=100.0,
                                 vpresent_start_arc=0.0,
                                 vpresent_end_arc=180.0,
                                 vpresent_num_sides_per_texture=4)

      # Create atleast one image ref.
      img_ref = opendocument.createNode('vpresent:fill-image-ref')
      self.mPanorama.addChild(img_ref)

      # Build shape geometry.
      self.mPanorama._update()

   def redo(self):
      self.mScene.addChild(self.mPanorama)
      self.mPresentationModel.pageChanged(self.mPage, refreshView=True)

   def undo(self):
      self.mScene.subChild(self.mPanorama)
      self.mPresentationModel.pageChanged(self.mPage, refreshView=True)


class AddBillboard(QtGui.QUndoCommand):
   def __init__(self, presModel, page, parent=None):
      QtGui.QUndoCommand.__init__(self, 'Add Billboard', parent)

      self.mPresentationModel = presModel
      self.mPage = page
      self.mScene = self.mPage.find('./dr3d:scene')
      if self.mScene is None:
         self.mScene = self.mPage.addChild(opendocument.createNode('dr3d:scene'))


      self.mBillboard =  \
         opendocument.createNode('vpresent:billboard',
                                 vpresent_width=1.0,
                                 vpresent_height=1.0,
                                 vpresent_width_res=1,
                                 vpresent_height_res=1)

      # Build shape geometry.
      self.mBillboard._update()

   def redo(self):
      self.mScene.addChild(self.mBillboard)
      self.mPresentationModel.pageChanged(self.mPage, refreshView=True)

   def undo(self):
      self.mScene.subChild(self.mBillboard)
      self.mPresentationModel.pageChanged(self.mPage, refreshView=True)

class AddFisheye(QtGui.QUndoCommand):
   def __init__(self, presModel, page, parent=None):
      QtGui.QUndoCommand.__init__(self, 'Add Fisheye', parent)

      self.mPresentationModel = presModel
      self.mPage = page
      self.mScene = self.mPage.find('./dr3d:scene')
      if self.mScene is None:
         self.mScene = self.mPage.addChild(opendocument.createNode('dr3d:scene'))


      self.mFisheye =  \
         opendocument.createNode('vpresent:fisheye',
                                 dr3d_size=10.0,
                                 vpresent_latres=50,
                                 vpresent_lonres=50,
                                 vpresent_span=180.0)

      # Build shape geometry.
      self.mFisheye._update()

   def redo(self):
      self.mScene.addChild(self.mFisheye)
      self.mPresentationModel.pageChanged(self.mPage, refreshView=True)

   def undo(self):
      self.mScene.subChild(self.mFisheye)
      self.mPresentationModel.pageChanged(self.mPage, refreshView=True)


class InsertMaterial(QtGui.QUndoCommand):
   def __init__(self, document, materialModel, fileName, displayName,
                row=None, parent=None):

      if row is None:
         desc = 'Add Material'
      else:
         desc = 'Insert Material'
      QtGui.QUndoCommand.__init__(self, desc, parent)

      self.mDocument = document
      self.mMaterialModel = materialModel
      self.mRow = row
      self.mMerged = None
      if self.mRow is None:
         self.mRow = self.mMaterialModel.rowCount()

      # Generate a unique fill image name.
      fill_image_name = self.mDocument.generateFillImageName()

      # Create a new fill image.
      self.mNewFillImage = opendocument.createNode('draw:fill-image',
         draw_name=fill_image_name, draw_display_name=displayName,
         xlink_href=fileName)

      # Generate a unique material name.
      material_name = self.mDocument.generateMaterialName()

      # Create a new material style.
      self.mNewStyle = opendocument.createNode('style:style',
         style_name=material_name, style_family='graphic',
         style_display_name="New Material")

      # Create graphics properties element and add it to the material style.
      self.mGraphicProperties = \
         opendocument.createNode('style:graphic-properties',
                                 draw_fill_image_name=fill_image_name)
      self.mNewStyle.addChild(self.mGraphicProperties) 

      # Locate the document's style node.
      self.mStylesNode = self.mDocument.docStyles.find('./office:styles')
      assert self.mStylesNode is not None

   def id(self):
      return MATERIAL_CREATE_ID

   def mergeWith(self, command):
      '''
      Determines if the given command can be merged with this command. This
      is allows if the command is a ChangeMaterialName instance. In that case, we
      treat the two-step process of creating a material and setting its initial
      name as a single command.
      '''
      result = False

      if command.id() == self.id():
         # We only merge the given command if we do not already have a merged
         # command.
         if self.mMerged is None and command.__class__ == ChangeMaterialName:
            self.mMerged = command
            result = True
      return result

   def redo(self):
      self.mMaterialModel.insertMaterial(self.mRow, self.mNewStyle)

      # Add new draw image to document.
      self.mStylesNode.addChild(self.mNewFillImage)
      self.mDocument.addFillImage(self.mNewFillImage)

   def undo(self):
      self.mMaterialModel.removeMaterial(self.mRow)

      # Remove draw image from document.
      self.mStylesNode.removeChild(self.mNewFillImage)
      self.mDocument.removeFillImage(self.mNewFillImage)


class ChangeMaterialName(QtGui.QUndoCommand):
   def __init__(self, materialModel, material, newName, parent = None):
      QtGui.QUndoCommand.__init__(self, 'Change Material Name', parent)
      self.mMaterialModel = materialModel
      self.mMaterial = material
      self.mNewName  = newName
      self.mOldName  = material.style_display_name

   def id(self):
      # This returns MATERIAL_CREATE_ID because setting the initial name for a
      # newly created material is treated as part of the creation process. See
      # InsertMaterial for more information.
      return MATERIAL_CREATE_ID

   def redo(self):
      self.mMaterial.style_display_name = self.mNewName
      #self.mMaterialModel.materialChanged(self.mMaterial)

   def undo(self):
      self.mMaterial.style_display_name = self.mOldName
      #self.mMaterialModel.materialChanged(self.mMaterial)


class RemoveMaterial(QtGui.QUndoCommand):
   def __init__(self, document, materialModel, row, oldMaterial, parent=None):
      QtGui.QUndoCommand.__init__(self, 'Remove Material', parent)
      self.mDocument = document
      self.mMaterialModel = materialModel
      self.mRow = row
      self.mOldMaterial = oldMaterial

   def redo(self):
      self.mMaterialModel.removeMaterial(self.mRow)

   def undo(self):
      self.mMaterialModel.insertMaterial(self.mRow, self.mOldMaterial)

class AssignMaterial(QtGui.QUndoCommand):
   def __init__(self, shape, materialName, parent = None):
      QtGui.QUndoCommand.__init__(self, 'Assign Material', parent)
      self.mShape = shape
      self.mMaterialName = materialName
      self.mOldMaterialName = shape.draw_style_name

   def redo(self):
      # Assign the new material to the shape.
      self.mShape.draw_style_name = self.mMaterialName
      self.mShape._materialAssignmentDirty = True
      self.mShape._update()

      # Update the OpenSG widget.
      env = environ.Environment()
      env.updateGL()

   def undo(self):
      # Assign the old material to the shape.
      self.mShape.draw_style_name = self.mOldMaterialName
      self.mShape._materialAssignmentDirty = True
      self.mShape._update()

      # Update the OpenSG widget.
      env = environ.Environment()
      env.updateGL()

class AssignPageStyle(QtGui.QUndoCommand):
   def __init__(self, document, page, styleName, parent=None):
      title = "Assign Page Style '%s'"%styleName
      QtGui.QUndoCommand.__init__(self, title, parent)
      self.mDocument = document
      self.mPage = page
      
      self.mStyleName = styleName
      self.mOldStyleName = str(page.draw_style_name)

   #def id(self):
   #   return PAGE_STYLE_CREATE

   def redo(self):
      # XXX: Do we need to handle the case where the referenced style does
      #      not have the correct children?
      self.mPage.draw_style_name = self.mStyleName
      # The UI should not allow us to assign a style that does not exist.
      assert self.mStyleName == None or self.mDocument.getPageStyle(self.mStyleName) is not None
      self.mPage._dirty = True
      self.mPage._update()

      # Update the OpenSG widget.
      env = environ.Environment()
      env.updateGL()

   def undo(self):
      self.mPage.draw_style_name = self.mOldStyleName
      self.mPage._dirty = True
      self.mPage._update()

      # Update the OpenSG widget.
      env = environ.Environment()
      env.updateGL()

class PresentationModel(QtCore.QAbstractListModel):
   mime_type = 'application/vpresent-slides'

   def __init__(self, document, parent=None):
      QtCore.QAbstractListModel.__init__(self, parent)

      self.mDocument = document
      self.mPres = self.mDocument.content.find('./office:body/office:presentation')

   def addPage(self, newPage):
      self.insertPage(self.rowCount(), newPage)

   def insertPage(self, row, newPage):
      """
      Insert a page into the presentation.
      @param row Index into the list of pages, not presentation children.
      """
      assert row >= 0
      pages = self.mDocument.pages[:]
      # If there are no pages, insert at the beginning.
      if len(pages) == 0:
         idx = 0
      else:
         # If inserting in middle, insert at the position of the previous page.
         if row < len(pages):
            prev = pages[row]
            idx = self.mPres._children.index(prev)
         # Append to the position of the last page.
         else:
            prev = pages[-1]
            idx = self.mPres._children.index(prev) + 1

      # Update the model with our row, but the presentation with
      # the child index.
      self.beginInsertRows(QtCore.QModelIndex(), row, row)
      self.mPres.insertChild(idx, newPage)
      self.endInsertRows()

      # We have to update the thumbnail.
      self.refreshThumbnail(self.index(row))

   def removePage(self, rowOrPage):
      """
      Remove a page from the presentation.
      @param rowOrPage Row index or a page object. If it is a row index then it
                       is an index into the list of pages, not presentation children.
      """
      if rowOrPage is None:
         return

      # Handle both cases of index and page object.
      if type(rowOrPage) == types.IntType:
         row = rowOrPage
         page = self.mDocument.pages[row]
      else:
         page = rowOrPage
         row = self.mDocument.pages.index(page)

      # Get the child index for the page object.
      idx = self.mPres._children.index(page)

      # Update the model with our row, but the presentation with
      # the child index.
      self.beginRemoveRows(QtCore.QModelIndex(), row, row)
      self.mPres.subChild(idx)
      self.endRemoveRows()

   def refreshAllThumbnails(self):
      for row in xrange(self.rowCount()):
         idx = self.index(row)
         page = self.data(idx, QtCore.Qt.UserRole)
         if page.getRootNode() is not None:
            img = gThumbnailGenerator.generateThumbnail(page.getRootNode())
            if img is not None:
               img = img.scaled(64, 64)
               pixmap = QtGui.QPixmap.fromImage(img)
               self.setData(idx, pixmap, QtCore.Qt.DecorationRole)
            else:
               print "WARNING: Could not generate thumbnail."

   def refreshThumbnail(self, index, refreshScene = True):
      osg.commitChanges()
      page = self.data(index, QtCore.Qt.UserRole)
      if page.getRootNode() is not None:
         # Don't need to pass root because it is already the root of the
         # main GLWidget. (If this becomes a problem, pass page.getRootNode().
         img = \
            gThumbnailGenerator.generateThumbnail(refreshScene = refreshScene)
         if img is not None:
            img = img.scaled(64, 64)
            pixmap = QtGui.QPixmap.fromImage(img)
            self.setData(index, pixmap, QtCore.Qt.DecorationRole)
         else:
            print "WARNING: Could not generate thumbnail."

   def pageChanged(self, page, refreshView=False):
      """ Slot that is called when a page's state changes
          without going through the setData method.
      """

      if page in self.mDocument.pages:
         row = self.mDocument.pages.index(page)
         changed_index = self.index(row)

         # If the OpenGL view of the slide changed refresh the main GLWidget
         # and the thumbnail. (Refreshing the thumbnail emits a dataChanged)
         if refreshView:
            # Update the main GLwidget.
            self.emit(QtCore.SIGNAL("refreshGLWidget"))

            # Update the thumbnail.
            self.refreshThumbnail(changed_index)
         else:
            self.emit(QtCore.SIGNAL("dataChanged(const QModelIndex &, const QModelIndex &)"),
               changed_index, changed_index)

   def onPresentationChanged(self):
      """ Slot that is called when the presentation has changed. This will
          force all views to be updated.
      """
      self.emit(QtCore.SIGNAL("modelReset()"))

   def flags(self, index):
      default_flags = QtCore.QAbstractListModel.flags(self, index)

      default_flags |= QtCore.Qt.ItemIsEditable
      if index.isValid():
         return QtCore.Qt.ItemIsDragEnabled | QtCore.Qt.ItemIsDropEnabled | default_flags
      else:
         return QtCore.Qt.ItemIsDropEnabled | default_flags

   def data(self, index, role=QtCore.Qt.DisplayRole):
      """ Returns the data representation of each slide in the presentation.
      """
      if not index.isValid():
         return QtCore.QVariant()

      # Get the cluster node we want data for.
      page = self.mDocument.pages[index.row()]

      if role == QtCore.Qt.UserRole:
         return page
      elif role == QtCore.Qt.UserRole+1:
         return page.find('./dr3d:scene')

      if page is not None:
         # Return an icon representing the operating system.
         if role == QtCore.Qt.DecorationRole:
            if page._thumbnailImage is not None:
               assert QtGui.QPixmap == type(page._thumbnailImage)
               return QtCore.QVariant(page._thumbnailImage)
            return QtCore.QVariant()
         # Return the name of the node.
         elif role == QtCore.Qt.DisplayRole or role == QtCore.Qt.EditRole:
            return QtCore.QVariant(str(page.draw_name))
       
      return QtCore.QVariant()

   def setData(self, index, value, role):
      """ Set the name of the page at the given index. """
      if not index.isValid():
         return False
      if index.row() >= self.rowCount():
         return False
      page = self.mDocument.pages[index.row()]
      if page is None:
         return False

      if role == QtCore.Qt.EditRole:
         new_name = unicode(value.toString())
         change_name = ChangeSlideName(self, page, new_name)
         undo_stack = self.mDocument.getUndoStack()
         undo_stack.push(change_name)
      elif role == QtCore.Qt.DecorationRole:
         assert QtGui.QPixmap == type(value)
         page._thumbnailImage = value

      self.emit(QtCore.SIGNAL("dataChanged(const QModelIndex &, constQModelIndex &)"), index, index)

      return True

   def supportedDropActions(self):
      # Hold shift when copying to change drag modes.
      return (QtCore.Qt.CopyAction | QtCore.Qt.MoveAction)

   def mimeTypes(self):
      """ List of types we can represent. """
      types = QtCore.QStringList()
      types.append(PresentationModel.mime_type)
      return types

   def mimeData(self, indexes):
      assert len(indexes) == 1
      page = self.data(indexes[0], QtCore.Qt.UserRole)
      assert page is not None

      mat_data   = QtCore.QByteArray()
      data_stream = QtCore.QDataStream(mat_data, QtCore.QIODevice.WriteOnly)
      text = "pages:%s" % indexes[0].row()
      txt_qstr = QtCore.QString(text)

      data_stream << txt_qstr

      # Create the MIME data object that will hold the serialized form of the
      # item being dragged.
      mime_data = QtCore.QMimeData()
      mime_data.setData(PresentationModel.mime_type, mat_data)

      return mime_data

   def dropMimeData(self, mimeData, action, row, column, parent):
      """
      Called when moving a image ref to a new location.

      If row and col == (-1, -1) then we need to place at the beginning.
      Otherwise row specifies the new index for the image ref.
      """
      if not parent.isValid():
         return False
      if not mimeData.hasFormat(PresentationModel.mime_type):
         return False
      if action == QtCore.Qt.IgnoreAction:
         return True
      if column > 0:
         return False

      # Extract the data from the dragged object.
      page_data = mimeData.data(PresentationModel.mime_type)

      # Deserialzied the dragged object into the page id.
      data_stream = QtCore.QDataStream(page_data,
                                       QtCore.QIODevice.ReadOnly)
      old_row_str = QtCore.QString()
      data_stream >> old_row_str
      old_row_str = str(old_row_str).lstrip('pages:')
      old_row = int(old_row_str)

      row = parent.row()
      if row < 0:
         row = 0

      page = self.data(self.index(old_row), QtCore.Qt.UserRole)
      assert page is not None

      # Create a MoveSlide command and push it onto the undo stack.
      undo_stack = self.mDocument.getUndoStack()
      move_slide = MoveSlide(self, page, old_row, row)
      undo_stack.push(move_slide)

      return True

   def rowCount(self, parent=QtCore.QModelIndex()):
      """ Returns the number of nodes in the current cluster configuration.
      """
      # If the parent is not valid, then we have no children.
      if parent.isValid():
         return 0
      else:
         return len(self.mDocument.pages)

class MaterialModel(QtCore.QAbstractListModel):
   """
   Represents all materials, graphic styles, in the current document.
   """
   def __init__(self, document, workDir, parent=None):
      QtCore.QAbstractListModel.__init__(self, parent)
      self.mWorkDir = workDir
      self.mDocument = document
      self.mPres = self.mDocument.content.find('./office:body/office:presentation')
      self.mAutoStyles = self.mDocument.content.find('./office:automatic-styles') 

   def addMaterial(self, newMaterial, newFillImage):
      self.insertMaterial(self.mAutoStyles.getNumChildren(), newMaterial)

   def insertMaterial(self, row, newMaterial):
      """
      Insert a material into the presentation.
      @param row Index into the list of materials, not presentation children.
      """
      assert row >= 0

      # NOTE: We can not let the user add a material before the special 'None' material.
      if 0 == row:
         print "Can't insert a page style before 'None' material."
         row = 1

      materials = self.mDocument.materials[:]
      # If there are no materials, insert at the beginning.
      if len(materials) == 0:
         idx = 0
      else:
         # If inserting in middle, insert at the position of the previous material.
         if row < len(materials):
            prev = materials[row]
            idx = self.mAutoStyles._children.index(prev)
         # Append to the position of the last material.
         else:
            prev = materials[-1]
            idx = self.mAutoStyles._children.index(prev) + 1

      # Update the model with our row, but the presentation with
      # the child index.
      # NOTE: We need to account for the special 'None' page style
      #       by adding one to the row.
      self.beginInsertRows(QtCore.QModelIndex(), row, row)
      # Add new style to document. 
      auto_styles_node = self.mDocument.content.find('./office:automatic-styles') 
      auto_styles_node.insertChild(idx, newMaterial)
      # Add new style to style db.
      self.mDocument.addStyle(newMaterial)
      self.endInsertRows()

   def removeMaterial(self, rowOrMaterial):
      """
      Remove a material from the presentation.
      @param rowOrMaterial Row index or a material object. If it is a row index
                           then it is an index into the list of materials, not
                           presentation children.
      """
      if rowOrMaterial is None:
         return

      # Handle both cases of index and material object.
      if type(rowOrMaterial) == types.IntType:
         row = rowOrMaterial
         # NOTE: We can not remove the special value of None at row 0.
         if 0 == row:
            print "Can't remove the 'None' material."
            return
         material = self.mDocument.materials[row]
      else:
         material = rowOrMaterial
         row = self.mDocument.materials.index(material)

      # NOTE: We need to account for the special 'None' page style
      #       by adding one to the row.
      row += 1

      # Get the child index for the material object.
      idx = self.mAutoStyles._children.index(material)

      # Update the model with our row, but the presentation with
      # the child index.
      self.beginRemoveRows(QtCore.QModelIndex(), row, row)
      self.mAutoStyles.subChild(idx)
      self.endRemoveRows()

   def flags(self, index):
      default_flags = QtCore.QAbstractListModel.flags(self, index)
      if index.isValid():
         default_flags |= QtCore.Qt.ItemIsEditable
         default_flags |= QtCore.Qt.ItemIsDragEnabled
         return default_flags
      else:
         return default_flags

   def data(self, index, role=QtCore.Qt.DisplayRole):
      """ Returns the data representation of each material in the presentation.
      """
      if not index.isValid():
         return QtCore.QVariant()

      # Handle the special 'None' material first.
      if 0 == index.row():
         if role == QtCore.Qt.UserRole:
            return None
         elif role == QtCore.Qt.DisplayRole:
            return QtCore.QVariant("None")
         return QtCore.QVariant()

      # NOTE: We need to subtract 1 from the row index to account for
      #       the special 'None' material case.
      row = index.row() - 1

      # Get the material we want data for.
      mat = self.mDocument.materials[row]
      graphic_props = mat.find('./style:graphic-properties')
      fill_image_name = graphic_props.draw_fill_image_name
      fill_image = self.mDocument.getFillImage(fill_image_name)

      # Return the material reference as the UserRole.
      if role == QtCore.Qt.UserRole:
         return mat
      # Return the FillImage for the given material.
      elif role == QtCore.Qt.UserRole+1:
         return fill_image

      if mat is not None:
         # Return an icon representing the operating system.
         if role == QtCore.Qt.DecorationRole:
            # If we already have a thumbnail
            if hasattr(mat, '_thumbnail') and mat._thumbnail is not None:
               return QtCore.QVariant(mat._thumbnail)

            # Try to find the fill image texture.
            if fill_image is not None:
               # Create a thumbnail and return it.
               mat._thumbnail = fill_image._getThumbnail()
               return QtCore.QVariant(mat._thumbnail)

            return QtCore.QVariant()
         # Return the name of the node.
         elif role == QtCore.Qt.DisplayRole or role == QtCore.Qt.EditRole:
            if mat.style_display_name is None:
               return QtCore.QVariant(str(mat.style_name))
            return QtCore.QVariant(str(mat.style_display_name))
         elif role == QtCore.Qt.SizeHintRole:
            return QtCore.QVariant(QtCore.QSize(75, 75+15))
      return QtCore.QVariant()

   def setData(self, index, value, role):
      """ Set the name of the material at the given index. """
      if not index.isValid():
         return False

      # NOTE: We need to subtract 1 from the row index to account for
      #       the special 'None' material case.
      row = index.row() - 1

      if row >= self.rowCount():
         return False
      mat = self.mDocument.materials[row]
      if mat is None:
         return False

      if role == QtCore.Qt.EditRole:
         new_name = unicode(value.toString())
         change_name = ChangeMaterialName(self, mat, new_name)
         undo_stack = self.mDocument.getUndoStack()
         undo_stack.push(change_name)
      #elif role == QtCore.Qt.DecorationRole:
      #   assert QtGui.QPixmap == type(value)
      #   mat._thumbnailImage = value

      self.emit(QtCore.SIGNAL("dataChanged(const QModelIndex &, constQModelIndex &)"), index, index)

      return True

   def rowCount(self, parent=QtCore.QModelIndex()):
      """ Returns the number of materials in the current presentation.
      """
      # If the parent is not valid, then we have no children.
      if parent.isValid():
         return 0
      else:
         # NOTE: Add one for the 'None' material at index 0.
         return len(self.mDocument.materials) + 1

   def indexByName(self, materialName):
      for (idx, mat) in enumerate(self.mDocument.materials):
         if mat.style_name == materialName:
            # NOTE: Add one for the 'None' material at index 0.
            return self.index(idx+1)
      # Return the index for the special None background.
      return self.index(0)

   def mimeTypes(self):
      """ List of types we can represent. """
      types = QtCore.QStringList()
      types.append(opensg.Style.mime_type)
      return types

   def mimeData(self, indexes):
      assert len(indexes) == 1

      # Get the material that we want to drag.
      mat = self.data(indexes[0], QtCore.Qt.UserRole)

      # Account for the special case of the 'None' material.
      style_name = None
      if 0 == indexes[0].row():
         style_name = ''
      else:
         assert mat is not None
         style_name = mat.style_name

      mat_data   = QtCore.QByteArray()
      data_stream = QtCore.QDataStream(mat_data, QtCore.QIODevice.WriteOnly)
      text = "style-name:%s" % style_name
      txt_qstr = QtCore.QString(text)

      data_stream << txt_qstr

      # Create the MIME data object that will hold the serialized form of the
      # item being dragged.
      mime_data = QtCore.QMimeData()
      mime_data.setData(opensg.Style.mime_type, mat_data)

      return mime_data
