Friday, October 21, 2011

Nexsys: Connecting the NavigationWidget to the Main Window

In the last window, we started connecting our UI components from our NavigationWidget class to itself to start with some of our basic file system navigation.

Now, we're going to connect the system up to the Main Window, to the actions that we had created on the window itself.

This is a little trickier - because while the navigation widget knows its own relationships, we're going to need to determine out of a multitude of possible navigation widgets, which one to apply the action to from the main window - its not as simple as just connecting the signal to a slot on the widget.


(Side note: I came into work today, and my Linux box was dead, so I copied the files to my Windows laptop to keep working on the tutorials - and that's all I had to do to get it to work.  Its now a Windows based filesystem explorer....I love PyQt - I'll have to copy it to my fiancee's Macbook tonight and see it work on there too).


Assigning the Current Widget


The first step to getting this system to work, is we have to determine which of our NavigationWidget's are going to be current at any given time.  There are a couple of methods that we could use to do this - we can use the QTabWidget.currentWidget method, however, we won't know if the right or left tab is active.  We could check to see which of the tab widgets has focus - but if we're working in the command line edit, then neither tab would have focus at that time.

Instead, we'll need to track the last widget that the user was editing in, and treat that as our widget.

This is a pretty common and useful technique to controlling which widget you want to assign action to from a main window.  Often time's you'll have a few panels of different widgets, and some actions or buttons that will need to apply to the current widget - but using the focusing system means that the focus can be lost and not accurate.

The best way I've found to control this is to store our own pointer to the widget, and link to the QApplication.focusChanged signal.  Its easy to forget sometimes that there are application level signals and slots that can be used to control your whole application.

In this particular case, we're not even going to be listening to our widget's focus events - it won't have any.  The base QWidget class doesn't accept focus (it can be forced - but why force it?).  Instead, we'll look for when any of its children have focus.

So, under your self.ui_newfile_act.triggered signal connection in your nexsys/gui/nexsyswindow.py module, attach a new signal to the application instance by calling:

        app = QtGui.QApplication.instance()
        app.focusChanged.connect( self.updateCurrentNavigationWidget )

And once you've done that, add the following three methods after your createNewFile method:

    def currentNavigationWidget( self ):
        """
        Returns the currently active navigation widget.  A user can control \
        which widget is current by assigning focus to its children.
        
        :return     NavigationWidget || None
        """
        return self._currentNavigationWidget
    
    def setCurrentNavigationWidget( self, widget ):
        """
        Sets the currently active navigation widget to the inputed widget.
        
        :param      widget | NavigationWidget || None
        
        :return     bool (changed)
        """
        if ( widget == self._currentNavigationWidget ):
            return False
            
        self._currentNavigationWidget = widget
        return True
    
    def updateCurrentNavigationWidget( self, oldWidget, newWidget ):
        """
        Lookup the parent of the focused widget for our navigation widgets.
        
        :param      oldWidget | QWidget
                    newWidget | QWidget
        """
        # make sure we have a new widget that is focused
        if ( not newWidget ):
            return
            
        # check to see if the newly focused widget is a member of a
        # NavigationWidget class
        parentWidget = newWidget.parentWidget()
        if ( isinstance(parentWidget, NavigationWidget) ):
            self.setCurrentNavigationWidget(parentWidget)

The last step for this to work, is to pre-define your self._currentNavigationWidget property in your constructor.  Directly after your loadUi method, add:

        # load the ui
        nexsys.gui.loadUi( __file__, self )
        
        # define custom properties
        self._currentNavigationWidget = None
        
        # clear out the current tabs
        self.ui_left_tab.clear()
        self.ui_right_tab.clear()

Note: While its not required to pre-define member variables - you can theoretically assign them at any other point in your method - its a very, very, very good practice to always pre-define them.  This makes it easier on yourself and others reading your code to know what member variables a class has.  If we defined that member in the setCurrentNavigationWidget method, then we would not know its there necessarily.  Generally, the way I structure my constructor code is:

  1. Update default values for super constructor (if needed)
  2. Call the super constructor's __init__ method
  3. Load any UI to define additional members
  4. Set inherited member defaults via their setter methods
  5. Define new custom member defaults and pre-definitions
  6. Perform additional setup tasks (does not require connections to be made yet, so can be faster to avoid signal emission)
  7. Create all the connections
  8. Restore any parameters and call any methods that do require the signals to already be setup
At any rate, what we have just done here is setup a slot on our window to listen for anytime any widget has changed focus - as emitted by the main application instance.  We're then checking the widget's parentWidget (vs. calling parent since a QWidget could also be parented to a QLayout) to make sure the parent is a NavigationWidget instance.  If that's the case, we're going to call our setCurrentNavigationWidget slot.

Connect the Actions

Lets now go ahead and map our window level navigation actions to affect our current widget.

Go ahead and create 3 more connections:

        self.ui_goup_act.triggered.connect(     self.goUp )
        self.ui_goroot_act.triggered.connect(   self.goToRoot )
        self.ui_gohome_act.triggered.connect(   self.goHome )

As you can see, we're connecting our top level actions to slots that are going to match the names of our NavigationWidget's slots.  So lets go ahead and add our top-level methods to the window (make sure to add it in alphabetically):

    def goHome( self ):
        """
        Navigates to the user's home path for the current navigation widget. \
        Will return False if there is no widget found.
        
        :return     bool (success)
        """
        widget = self.currentNavigationWidget()
        if ( not widget ):
            return False
        
        widget.goHome()
        return True
    
    def goToRoot( self ):
        """
        Navigates up to the root path for the current navigation \
        widget.  Will return False if there is no widget found.
        
        :return     bool (success)
        """
        widget = self.currentNavigationWidget()
        if ( not widget ):
            return False
        
        widget.goToRoot()
        return True
    
    def goUp( self ):
        """
        Navigates up the folder hierarchy for the current navigation widget. \
        Will return False if there is no widget found.
        
        :return     bool (success)
        """
        widget = self.currentNavigationWidget()
        if ( not widget ):
            return False
        
        widget.goUp()
        return True

So, what we're doing here for each slot is first grabbing our current NavigationWidget instance and then applying that particular widget's slot for the top level action.  This allows us to easily change exactly which widget we're applying our action's to based on the user's focus changing.  The other way we would have had to do it would be to continuously connect and disconnect the top level window actions as the user changed widgets, which is time consuming and unnecessary.  It's much easier to develop this sort of a current widget system for your applications.

Refreshing the Command Line


Next, lets link our command line widget to reflect the current path hierarchy that we're working in - as the current widget changes, lets update the command path label to reflect that widget's current path.

To do this, lets create a new method:

    def updateCommandPath( self ):
        """
        Updates the command line widget's path to reflect the current path \
        for the current navigation widget.
        """
        widget = self.currentNavigationWidget()
        if ( not widget ):
            return
        
        self.ui_cmdline_lbl.setText(widget.currentPath() + " ")

We'll also force the update to the path to occur when the user alters the current navigation widget, so update your setCurrentNavigationWidget to read:

        if ( widget == self._currentNavigationWidget ):
            return False
            
        self._currentNavigationWidget = widget
        self.updateCommandPath()
        return True

Now, if you navigate around to different filepaths and click between your two navigation widgets - you'll see your command line path change.

After all of these changes, your nexsys/gui/nexsyswindow.py file should look like this:

#!/usr/bin/python ~workspace/nexsys/gui/nexsyswindow.py

""" Defines the main NexsysWindow class. """

# define authorship information
__authors__     = ['Eric Hulser']
__author__      = ','.join(__authors__)
__credits__     = []
__copyright__   = 'Copyright (c) 2011'
__license__     = 'GPL'

# maintanence information
__maintainer__  = 'Eric Hulser'
__email__       = 'eric.hulser@gmail.com'

from PyQt4 import QtGui

import nexsys.gui

from nexsys.gui.navigationwidget import NavigationWidget

class NexsysWindow(QtGui.QMainWindow):
    """ Main Window class for the Nexsys filesystem application. """
    
    def __init__( self, parent = None ):
        super(NexsysWindow, self).__init__(parent)
        
        # load the ui
        nexsys.gui.loadUi( __file__, self )
        
        # define custom properties
        self._currentNavigationWidget = None
        
        # clear out the current tabs
        self.ui_left_tab.clear()
        self.ui_right_tab.clear()
        
        # hide the headers
        self.ui_left_tab.tabBar().hide()
        self.ui_right_tab.tabBar().hide()
        
        # create the default tabs
        self.ui_left_tab.addTab(NavigationWidget(self), '')
        self.ui_right_tab.addTab(NavigationWidget(self), '')
        
        # create connections
        self.ui_newfile_act.triggered.connect( self.createNewFile )
        
        self.ui_goup_act.triggered.connect(     self.goUp )
        self.ui_goroot_act.triggered.connect(   self.goToRoot )
        self.ui_gohome_act.triggered.connect(   self.goHome )
        
        app = QtGui.QApplication.instance()
        app.focusChanged.connect( self.updateCurrentNavigationWidget )
    
    def createNewFile( self ):
        """
        Prompts the user to enter a new file name to create at the current
        path.
        """
        QtGui.QMessageBox.information( self, 
                                       'Create File', 
                                       'Create New Text File' )
    
    def currentNavigationWidget( self ):
        """
        Returns the currently active navigation widget.  A user can control \
        which widget is current by assigning focus to its children.
        
        :return     NavigationWidget || None
        """
        return self._currentNavigationWidget
    
    def goHome( self ):
        """
        Navigates to the user's home path for the current navigation widget. \
        Will return False if there is no widget found.
        
        :return     bool (success)
        """
        widget = self.currentNavigationWidget()
        if ( not widget ):
            return False
        
        widget.goHome()
        return True
    
    def goToRoot( self ):
        """
        Navigates up to the root path for the current navigation \
        widget.  Will return False if there is no widget found.
        
        :return     bool (success)
        """
        widget = self.currentNavigationWidget()
        if ( not widget ):
            return False
        
        widget.goToRoot()
        return True
    
    def goUp( self ):
        """
        Navigates up the folder hierarchy for the current navigation widget. \
        Will return False if there is no widget found.
        
        :return     bool (success)
        """
        widget = self.currentNavigationWidget()
        if ( not widget ):
            return False
        
        widget.goUp()
        return True
    
    def setCurrentNavigationWidget( self, widget ):
        """
        Sets the currently active navigation widget to the inputed widget.
        
        :param      widget | NavigationWidget || None
        
        :return     bool (changed)
        """
        if ( widget == self._currentNavigationWidget ):
            return False
            
        self._currentNavigationWidget = widget
        self.updateCommandPath()
        return True
    
    def updateCommandPath( self ):
        """
        Updates the command line widget's path to reflect the current path \
        for the current navigation widget.
        """
        widget = self.currentNavigationWidget()
        if ( not widget ):
            return
        
        self.ui_cmdline_lbl.setText(widget.currentPath() + " ")
    
    def updateCurrentNavigationWidget( self, oldWidget, newWidget ):
        """
        Lookup the parent of the focused widget for our navigation widgets.
        
        :param      oldWidget | QWidget
                    newWidget | QWidget
        """
        # make sure we have a new widget that is focused
        if ( not newWidget ):
            return
            
        # check to see if the newly focused widget is a member of a
        # NavigationWidget class
        parentWidget = newWidget.parentWidget()
        if ( isinstance(parentWidget, NavigationWidget) ):
            self.setCurrentNavigationWidget(parentWidget)


Coming up Next

 So, while we've got some pretty cool basic stuff in there - I'm sure you're noticing some things are quite clunky right now.  We're going to need to add a better way to navigate the folders than having to type the direct path to the folder in our browser.  Next, we'll look at a new way to control user keyboard interaction using Qt's event filtering system.

Also, you'll have noticed that the command line updating with the toggle of the current navigation widget doesn't reflect changes when the path itself changes - we'll fix that in the next round as well by starting to create some custom pyqtSignals for our NavigationWidget to communicate up to the NexsysWindow.

10 comments:

  1. Hi Eric!

    Long time no see!
    It's Wei here from drd (well.. no longer)
    Hows things? Are you still in Sydney?

    I was wondering do you have any suggestions about implementing a node based graphic view? I had a few tries in the past but no fruits (with pyqt though), it was either hard to sketch out a paint() method or i was lost in the data layer.

    Are there any qt/pyqt projects you heard about that have some source codes for study?

    Please throw me any king of thought you have :D
    (my email is here: acgmsd@gmail.com)

    Cheers and have a nice break!

    ReplyDelete
  2. Hi Eric,

    I've just finished reading all your posts doing the same at home. Really cool stuffs, learned a lot about Qt.

    Are you planning in posting some updates for Nexsys in the future? I've seen that this one (the last post) is pretty old. Just asking to know if I could expect learning even further from you.

    Cheers

    Jerome D.

    ReplyDelete
  3. Hi Jerome,

    This ended up being a pretty busy year for me so I haven't been able to get back to this in a while. I do plan to continue this tutorial, but will most likely migrate it all as a course at my new website,
    http://docs.projexsoftware.com when I get some time.

    Thanks - glad you enjoyed working through this and found it helpful! Let me know if you have any suggestions or thoughts on improvements or anything.

    Eric

    ReplyDelete
    Replies
    1. Hi Eric,

      Thanks for your answer, glad to hear you are busy, it's always better than not having anything to do :)

      For the moment I do not have any comments as I finished following along doing Nexsys and had it working in my end the same way you have it on yours so it mean the tutorial is great and straight forward.

      The only differences I did is loading the uic module in the same place as the QtCore and QtGui

      From PyQt4 import QtCore, QtGui, uic
      and not import PyQt4.uic

      Is there any particular reason that it should be better splitting it as you did? Does the uic module have anything in particular that it is better loading it separately?

      Cheers

      Delete
  4. More, please :) Love this series, one of the better introductions to pyqt online. Perhaps turn it into a book?

    ReplyDelete
  5. I have to say, I was heartbroken when I got to this post and realized it was the last. :(

    But it was worth it anyway! This series of posts was invaluable in wrapping my head around this humongous and new (to me) framework. Thank you so much.

    ReplyDelete
  6. Thanks for the comments guys! I have wanted to start this back up as I have a lot of new things that I can add to this tutorial set (and start more)....just need some more time. Perhaps I'll start allocating a few hours a week to furthering this series.

    I'd also suggest checking out some of my open source projects that help make Qt development a bit easier:

    xqt is a project to create a layer above PyQt/PySide to allow cross development
    https://github.com/ProjexSoftware/xqt/

    projexui is a library of additional widgets that extend Qt's base functionality
    https://github.com/ProjexSoftware/projexui/

    ReplyDelete
    Replies
    1. That would be wonderful! Thank you so much for all you have already done, I've learned a lot in the last few hours... Is there any way to donate for this project?

      Delete
  7. Thanks a lot for such a great set of tutorials, fantastically written. Looking forward to checking out your other project, as I'm currently using PySide. On PySide vs PyQt, I'd be interested to know if you have a preference and why that might be.
    I see you might be a Sydney sider, don't suppose you know of any groups that meet on Python VFX(Houdini).

    Thanks Nick

    ReplyDelete
  8. Hi Nick --

    Thanks for the comment! As for PySide vs. PyQt, I personally prefer PyQt but it depends on the scope of your project.

    PySide started being developed by Nokia after Qt was bought from Trolltech as an alternative to PyQt and to keep all the code in house. They also shifted the licensing from GPL to LGPL so you could use PySide in commercial products without having to purchase a commercial license. However, the project started later than PyQt and is more bug prone. (Don't try to pass None through a signal/slot connection cross-thread or it will crash your application).

    Nokia has now sold the Qt project to the Qt Foundation, and are not actively maintaining PySide anymore. PyQt already has support for Python 3 and Qt5, it doesn't look like PySide ever will.

    So short answer, if you're looking to make a commercial app and avoid a commercial license fee, I'd use PySide. Otherwise, I'd use PyQt.

    As for Sydney -- I need to spend a little time updating this blog...I wrote it when I was out there in 2010, but have been back in the US for the last 4 years, and am now out of the VFX industry....so I have no idea about any Python groups out there....

    Eric

    ReplyDelete