Getting Started with dip.shell

A shell is an abstraction of an application and its user interface. In simple terms it is a view. An instance of a shell implements the IView interface and can be adapted to the IShell interface.

A shell visualises actions and provides areas where tools can display their own views. A shell provides a main view that is the focus of the user’s normal interaction with the application.

Different shells may visualise actions and views in different ways. For example a shell specifically designed for mobile devices may only allow one view to be visible at a time and to provide device specific methods for the user to switch between views.

A tool is a sub-set of an application’s functionality that implements a set of logically related operations. Breaking an application down to a number of tools encourages the use of components and the ability to re-use them in other applications. A tool is independent of any particular shell implementation.

dip includes some standard tools that implement functionality common to many applications. The most important of these is the ModelManagerTool which handles the lifecycle of application models. It provides actions that implement the standard New, Open, Save, Save As and Close operations. When interacting with the user these will automatically take account of the managed models and tools that are available, and the storage where managed models can be read from or written to.

In the following sections we describe the different aspects of using a shell.

Creating and Displaying a Shell

Like other views, shells are normally defined declaratively, i.e. a shell factory is created which, when called, will then create a toolkit specific instance of the shell.

The following is a complete example (which you can be downloaded from here) which uses the MainWindowShell included with dip.

from dip.shell.shells.main_window import MainWindowShell
from dip.ui import Application


# Every application needs an Application.
app = Application()

# Define the shell.
view_factory = MainWindowShell(window_title="Trivial Shell")

# Create the shell.
view = view_factory()

# Make the shell visible.
view.visible = True

# Enter the event loop.
app.execute()

The code should be self-explanatory.

Adding a Tool to a Shell

There are several ways to add a tool to a shell. This example (which you can download from here) is identical to the previous example except that it passes the QuitTool class in a list as the tool_factories attribute.

from dip.shell.shells.main_window import MainWindowShell
from dip.shell.tools.quit import QuitTool
from dip.ui import Application


# Every application needs an Application.
app = Application()

# Define the shell.  Every shell created by the factory will have a QuitTool
# instance.
view_factory = MainWindowShell(tool_factories=[QuitTool], window_title="Quit Shell")

# Create the shell.
view = view_factory()

# Make the shell visible.
view.visible = True

# Enter the event loop.
app.execute()

Every instance of the shell will automatically have an instance of QuitTool as a tool. Alternative we could have added it later, as follows:

view_factory.tool_factories.append(QuitTool)

Finally we could have added an instance of the tool to a specific instance of the shell, as follows:

IShell(view).tools.append(QuitTool())

More on the Quit Tool

The main purpose of the Quit Tool is to provide an action that, when triggered, quits the application. However the application may not be in a state where it can quit without losing data. Therefore the Quit Tool looks at all the other tools in the shell to see which implement the IQuitVeto interface. It then looks at the interface’s reasons attribute for a list of the reasons why the attempt to quit should be vetoed. Any reasons are displayed to the user and the quit is abandoned. So long as no tool provides a reason the quit goes ahead.

Writing a Tool

We now work through the implementation of a new tool. The code that implements the tool is shown below. (The complete example can be downloaded from here.) The tool is added to the shell in the same way as the Quit Tool.



@implements(ITool)
class ZoomTool(Model):
    """ The ZoomTool class implements a simple shell tool that zooms in and out
    of its view.
    """

    # The increment (decrement) when zooming in (out).
    increment = Float(1.25)

    # This action shows if the view is at its normal size and toggles between
    # its normal size and previous size.
    normal_size = Action(checked=True)

    # The collection of actions.  By specifying 'text' we are hinting to the
    # toolkit that we would like the actions to be placed in a sub-menu.
    zoom = ActionCollection('zoom_in', 'normal_size', 'zoom_out', text="Zoom",
            within='dip.ui.collections.tools')

    # The Zoom In action.
    zoom_in = Action()

    # The Zoom Out action.
    zoom_out = Action()

    # The current font size.
    _current_size = Float()

    # The default, i.e. normal, font size.
    _default_size = Float()

    # The previous, non-default font size.
    _previous_size = Float()

    # Set if the font uses pixels rather than points.
    _uses_pixels = Bool()

    @normal_size.triggered
    def normal_size(self):
        """ Invoked when the Normal Size action is triggered. """

        self._set_size(
                self._default_size if self.normal_size.checked
                else self._previous_size)

    @ITool.views.default
    def views(self):
        """ Invoked to return the default views. """

        from PyQt5.QtCore import Qt
        from PyQt5.QtWidgets import QLabel

        return [QLabel("Hello world", alignment=Qt.AlignCenter)]

    @zoom_in.triggered
    def zoom_in(self):
        """ Invoked when the Zoom In action is triggered. """

        self._set_size(self._current_size * self.increment)

    @zoom_out.triggered
    def zoom_out(self):
        """ Invoked when the Zoom Out action is triggered. """

        self._set_size(self._current_size / self.increment)

    @_current_size.default
    def _current_size(self):
        """ Invoked to return the default current font size. """

        return self._default_size

    @_default_size.default
    def _default_size(self):
        """ Invoked to return the default default font size. """

        from PyQt5.QtWidgets import QApplication

        font = QApplication.font()

        # Remember if the default size is in pixels or points.
        default_size = font.pointSizeF()
        if default_size < 0:
            default_size = font.pixelSize()
            self._uses_pixels = True
        else:
            self._uses_pixels = False

        return default_size

    @_previous_size.default
    def _previous_size(self):
        """ Invoked to return the default previous font size. """

        return self._default_size

    def _set_size(self, new_size):
        """ Set the font size of all views and update the internal state. """

        # Set the size.
        for view in self.views:
            # Get the toolkit specific widget.
            qview = unadapted(view)

            font = qview.font()

            if self._uses_pixels:
                font.setPixelSize(round(new_size))
            else:
                font.setPointSizeF(new_size)

            qview.setFont(font)

        # See if setting to the default size.
        if abs(new_size - self._default_size) < 0.0001:
            self.normal_size.checked = True
        else:
            self.normal_size.checked = False

        # Only remember the previous size if it isn't the default.
        if abs(self._current_size - self._default_size) > 0.0001:
            self._previous_size = self._current_size

        self._current_size = new_size


The tool allows its view to be zoomed in and out of. This does this simply by changing the font size of the view. It provides three actions: one to zoom in; one to zoom out; and one to toggle between the normal size and the previous non-normal size.

We will now walk through this code. Note that we use a mixture of toolkit independent and toolkit specific API calls and that, as a consequence, the tool requires a PyQt5 based toolkit. We have done this because the toolkit independent API has no support for fonts. Should font support be added in the future it would be easy to rewrite this tool to be toolkit independent and be reusable in any dip application.

The following code is the start of the class definition. A tool is a model that implements the ITool interface.



@implements(ITool)
class ZoomTool(Model):
    """ The ZoomTool class implements a simple shell tool that zooms in and out
    of its view.
    """

The following code defines the value used as a multiplier used when zooming in and used as a divisor when zooming out. As it is an attribute its value can easily be overridden when the tool is created.

    increment = Float(1.25)

The following code defines the checkable action used to toggle the view between its normal size and its previous non-normal size. By default the action will be checked. Because we haven’t specified either the action’s text or identifier they will both be derived from the name of the attribute.

    normal_size = Action(checked=True)

The following code defines the action collection that contains the actions provided by the tool.

    zoom = ActionCollection('zoom_in', 'normal_size', 'zoom_out', text="Zoom",
            within='dip.ui.collections.tools')

The first three arguments are the identifiers of the actions in the collection. They will be visualised in this order.

The text attribute is provided as a hint to the toolkit that we would like the collection to be visualised as part of a hierachy (typically a sub-menu). The toolkit is free to ignore the hint.

The within attribute specifies where the collection will be visualised. Its value will either be the identifier of another action collection or (as in this case) the idenfifier of a well known collection.

The following code defines the remaining actions.


    # The Zoom In action.
    zoom_in = Action()

    # The Zoom Out action.
    zoom_out = Action()

The following code defines various attributes used internally by the tool.


    # The current font size.
    _current_size = Float()

    # The default, i.e. normal, font size.
    _default_size = Float()

    # The previous, non-default font size.
    _previous_size = Float()

    # Set if the font uses pixels rather than points.
    _uses_pixels = Bool()

The following code defines the method that is called when the normal_size action is triggered. It simply sets the size to the default size or the previous non-default size depending on the action’s checked state.


    @normal_size.triggered
    def normal_size(self):
        """ Invoked when the Normal Size action is triggered. """

        self._set_size(
                self._default_size if self.normal_size.checked
                else self._previous_size)

Alternatively the observe() decorator could have been used as follows:

@observe('normal_size.checked')
def __normal_size_checked_changed(self, change):
    """ Invoked when the Normal Size action's checked attribute changes. """

The following code defines the method that is called when the views attribute is first referenced. It returns a single element list containing the default view. Note that we are returning a toolkit specific widget that has not been adapted to the IView interface. Because dip knows what interface is needed, and that the PyQt5 toolkit provides an appropriate adapter, then the widget will be adapted automatically.


    @ITool.views.default
    def views(self):
        """ Invoked to return the default views. """

        from PyQt5.QtCore import Qt
        from PyQt5.QtWidgets import QLabel

        return [QLabel("Hello world", alignment=Qt.AlignCenter)]

The following code defines the methods that are called when the zoom in and zoom out actions are triggered. They simply update the current size.


    @zoom_in.triggered
    def zoom_in(self):
        """ Invoked when the Zoom In action is triggered. """

        self._set_size(self._current_size * self.increment)

    @zoom_out.triggered
    def zoom_out(self):
        """ Invoked when the Zoom Out action is triggered. """

        self._set_size(self._current_size / self.increment)

The remaining methods implement the actual setting of the size of the font and should be self-explanatory.

More on Actions and Action Collections

When a tool is added to a shell the shell will first introspect the tool for any action collections and for any actions. Action collections and actions found in this way are added to the shell automatically. However the order in which they are added is random - except that all collections will be added before any actions.

If a tool provides a non-empty list as its actions attribute then the introspection of the tool is not done and only those action collections and actions in the list are added. They are added in the order in which they appear in the list.

If, in the previous example, we didn’t prefer to place the actions in their own sub-menu then we could have simply omitted the definition of the action collection. However we would then have to specify the within attribute of each of the actions. We would also have had to specify the value of the actions attribute in order to provide an explicit order for the actions. The corresponding code would be as follows:

normal_size = Action(checked=True, within='dip.ui.collections.tools')

zoom_in = Action(within='dip.ui.collections.tools')

zoom_out = Action(within='dip.ui.collections.tools')

@ITool.actions.default
def actions(self):
    """ Invoked to return the explicitly ordered actions. """

    return [self.zoom_in, self.normal_size, self.zoom_out]

Using the Model Manager

A very common application pattern is to open a file, read the model from it, use a tool to modify the model, and save the model by writing it back to the file. A user can usually also create a new model and save a model under a different name. These operations are usually invoked by the user using the traditional New, Open, Save, Save As and Close actions.

dip provides the ModelManagerTool which implements those traditional actions on behalf of tools and the models they can handle. The model manager interacts with the user, using appropriate dialogs and wizards, guiding them through the selection of models, storage locations and tools depending on what is available.

In order for a tool to use the model manager it must implement, or be able to be adapted to, the IManagedModelTool interface. Likewise any models must implement, or be able to be adapted to, the IManagedModel interface. The final requirement is that models are read from and written to storage using the dip.io module.

Note

The dip.io module is covered in detail in Getting Started with dip.io. For now we will just cover the essentials needed for reading and writing our example models.

We will now work through the implementation of a simple, but complete, Python source code editor based on the QScintilla editor widget shown below. (The example can also be downloaded from here.)

import sys

from dip.io import IFilterHints, IoManager
from dip.io.codecs.unicode import UnicodeCodec, IUnicodeDecoder, IUnicodeEncoder
from dip.io.storage.filesystem import FilesystemStorageFactory
from dip.model import (adapt, Adapter, implements, Instance, notify_observers,
        unadapted)
from dip.shell import (BaseManagedModelTool, IManagedModel, IManagedModelTool,
        IShell)
from dip.shell.shells.main_window import MainWindowShell
from dip.shell.tools.dirty import DirtyTool
from dip.shell.tools.model_manager import ModelManagerTool
from dip.shell.tools.quit import QuitTool
from dip.ui import Action, Application


class PythonCode(object):
    """ The PythonCode class encapsulates the code that implements a single
    Python module.
    """

    def __init__(self):
        """ Invoked to return the default editor widget. """

        from PyQt5.Qsci import QsciLexerPython, QsciScintilla

        editor = QsciScintilla()

        # Configure the editor for Python.
        editor.setUtf8(True)
        editor.setLexer(QsciLexerPython(editor))
        editor.setFolding(QsciScintilla.PlainFoldStyle, 1)
        editor.setIndentationGuides(True)
        editor.setIndentationWidth(4)

        self.editor = editor


@adapt(PythonCode, to=IManagedModel)
class PythonCodeIManagedModelAdapter(Adapter):
    """ Adapt PythonCode to the IManagedModel interface. """

    # The native format is Unicode (specifically UTF-8).
    native_format = 'myorganization.formats.python_code'

    def __init__(self):
        """ Initialise the adapter. """

        # Convert the Qt signal to the corresponding dip attribute change.
        self.adaptee.editor.modificationChanged.connect(
                lambda modified: notify_observers(
                        'dirty', self, modified, not modified))

    @IManagedModel.dirty.getter
    def dirty(self):
        """ Invoked to get the dirty state. """

        return self.adaptee.editor.isModified()

    @dirty.setter
    def dirty(self, value):
        """ Invoked to set the dirty state. """

        self.adaptee.editor.setModified(value)


@adapt(PythonCode, to=[IFilterHints, IUnicodeDecoder, IUnicodeEncoder])
class PythonCodeCodecAdapter(Adapter):
    """ Adapt PythonCode to the IFilterHints, IUnicodeDecoder and
    IUnicodeEncoder interfaces.
    """

    # The filter to use if the code is being stored in a file.
    filter = "Python source files (*.py *.pyw)"

    def set_unicode(self, model, data):
        """ Set the code from a unicode string object. """

        model.editor.setText(data)
        model.editor.setModified(False)

        return model

    def get_unicode(self, model):
        """ Return the code as a unicode string object. """

        # Note that Python v2 will require this to be wrapped in a call to
        # unicode().
        return model.editor.text()


@implements(IManagedModelTool)
class PythonCodeEditorTool(BaseManagedModelTool):
    """ The PythonCodeEditorTool implements a tool for editing Python code.  It
    leaves the management of the lifecycle of the model containing the code to
    a model manager.
    """

    # The tool's identifier.
    id = 'myorganization.shell.tools.source_code_editor'

    # The action that toggles the use of line numbers by the editor.
    line_nrs = Action(text="Show Line Numbers", checked=False,
            within='dip.ui.collections.view')

    # To keep the application simple we only allow one model to be handled at a
    # time.
    model_policy = 'one'

    def create_views(self, model):
        """ Invoked to create the views for a model. """

        # We have chosen to use the editor widget to hold the code so the view
        # is actually in the model.
        view = model.editor

        # Configure the view according to the state of the action.
        self._configure_line_nrs(view)

        return [view]

    def handles(self, model):
        """ Check that the tool can handle the model. """

        # Because the application only has one type of model we could just
        # return True.
        return isinstance(model, PythonCode)

    @line_nrs.triggered
    def line_nrs(self):
        """ Invoked when the line number action is triggered. """

        self._configure_line_nrs(self.current_view)

    def _configure_line_nrs(self, view):
        """ Configure the state of line numbers for a view depending on the
        state of the line number action.
        """

        # Line numbers are configured by setting the width of the first margin.
        margin_width = 30 if self.line_nrs.checked else 0

        unadapted(view).setMarginWidth(0, margin_width)


# Every application needs an Application.
app = Application()

# Add our codec and support for filesystem storage.
IoManager.codecs.append(UnicodeCodec(format='myorganization.formats.python_code'))
IoManager.storage_factories.append(FilesystemStorageFactory())

# Define the shell.
view_factory = MainWindowShell(main_area_policy='single',
        tool_factories=[
                DirtyTool,
                lambda: ModelManagerTool(model_factories=[PythonCode]),
                PythonCodeEditorTool,
                QuitTool],
        window_title_template="[view][*]")

# Create the shell.
view = view_factory()

# If a command line argument was given try and open it as Python code.
if len(sys.argv) > 1:
    IShell(view).open('myorganization.shell.tools.source_code_editor',
            sys.argv[1], 'myorganization.formats.python_code')

# Make the shell visible.
view.visible = True

# Enter the event loop.
app.execute()

First of all we will look at the definition of our model.



class PythonCode(object):
    """ The PythonCode class encapsulates the code that implements a single
    Python module.
    """

    def __init__(self):
        """ Invoked to return the default editor widget. """

        from PyQt5.Qsci import QsciLexerPython, QsciScintilla

        editor = QsciScintilla()

        # Configure the editor for Python.
        editor.setUtf8(True)
        editor.setLexer(QsciLexerPython(editor))
        editor.setFolding(QsciScintilla.PlainFoldStyle, 1)
        editor.setIndentationGuides(True)
        editor.setIndentationWidth(4)

        self.editor = editor


We have chosen to use the QScintilla widget to hold the text of the Python code being edited - in effect we are combining the model and the view. This is convenient for this particular example but is a bad idea if we ever want to re-use the model in a different context. Another point to make about the model is that it doesn’t make any use of dip.

We have said that, to be managed by the model manager, a model must implement the IManagedModel interface. Therefore we need to provide an adapter between our model and that interface.



@adapt(PythonCode, to=IManagedModel)
class PythonCodeIManagedModelAdapter(Adapter):
    """ Adapt PythonCode to the IManagedModel interface. """

    # The native format is Unicode (specifically UTF-8).
    native_format = 'myorganization.formats.python_code'

    def __init__(self):
        """ Initialise the adapter. """

        # Convert the Qt signal to the corresponding dip attribute change.
        self.adaptee.editor.modificationChanged.connect(
                lambda modified: notify_observers(
                        'dirty', self, modified, not modified))

    @IManagedModel.dirty.getter
    def dirty(self):
        """ Invoked to get the dirty state. """

        return self.adaptee.editor.isModified()

    @dirty.setter
    def dirty(self, value):
        """ Invoked to set the dirty state. """

        self.adaptee.editor.setModified(value)


A model that uses the dip.io module has one or more formats, i.e. ways in which the model can be encoded when being written to storage and decoded when being read from storage. A codec must be available that supports a particular format. One of these formats is the model’s native format which is used to select an appropriate codec when opening and saving the model. Other formats (and therefore codecs) may be used when importing or exporting the model.

The rest of the adapter implements support for the interface’s dirty attribute. The __init__() method ensures that when the user edits the text then any observers of the dirty attribute are invoked. The remaining methods implement a getter and setter for the attribute that delegate to the underlying editor widget.

We now need to make sure that the dip.io module is able to read and write our model from and to storage. The format will identify a codec. (We will come on to defining new codecs later on.) A codec will define interfaces for a decoder and an encoder and a model must implement those interfaces (or be able to be adapted to them) in order to be read and written. As we will see later we have chosen to use the UnicodeCodec codec provided by dip that uses a Unicode byte stream (specifically a UTF-8 byte stream). To use this codec a model must implement, or be able to be adapted to, the the IUnicodeDecoder and IUnicodeEncoder interfaces.

dip doesn’t make any assumptions about where a model may be stored, and an application should not care. In fact, new types of storage can be added to the dip.io module without requiring any change to an application. For example if support for some cloud storage is added then an application will automatically be able to open and save models in the cloud. The model manager will automatically take the new type of storage into account when interacting with the user.

The most common form of storage is, of course, the local filesystem. When the user uses a file dialog to specify the name of a file to open they expect that the dialog has applied a filter to the names of the files displayed so that only those that are valid at the time can be selected. Therefore we want our example to be able to specify appropriate filename filters. But the concept is specific to a particular form of storage, and our application isn’t supposed to know about particular forms of storage. We manage this contradiction by the use of hints. A hint is an interface that an object may optionally implement or provide an adapter for. Some other part of the system (the filesystem storage support in this case) can choose to test to see if the interface has been implemented for the object and, if so, extract the hinted at information. The dip.io module provides the IFilterHints interface for passing a filename filter hint.

So, getting back to our example, we need to provide adapters for the IFilterHints, IUnicodeDecoder and IUnicodeEncoder interfaces. We choose to implement these as a single adapter, shown below.



@adapt(PythonCode, to=[IFilterHints, IUnicodeDecoder, IUnicodeEncoder])
class PythonCodeCodecAdapter(Adapter):
    """ Adapt PythonCode to the IFilterHints, IUnicodeDecoder and
    IUnicodeEncoder interfaces.
    """

    # The filter to use if the code is being stored in a file.
    filter = "Python source files (*.py *.pyw)"

    def set_unicode(self, model, data):
        """ Set the code from a unicode string object. """

        model.editor.setText(data)
        model.editor.setModified(False)

        return model

    def get_unicode(self, model):
        """ Return the code as a unicode string object. """

        # Note that Python v2 will require this to be wrapped in a call to
        # unicode().
        return model.editor.text()


That completes the work we have to do for our model. We will now work through the implementation of the tool that allow us to edit our model. We will take this a step at a time.



@implements(IManagedModelTool)
class PythonCodeEditorTool(BaseManagedModelTool):
    """ The PythonCodeEditorTool implements a tool for editing Python code.  It
    leaves the management of the lifecycle of the model containing the code to
    a model manager.
    """

As we said earlier a tool that wants to make use of a model manager must implement the IManagedModelTool interface. In addition our tool is sub-classed from BaseManagedModelTool. This is an optional class that implements a lot of the housekeeping needed by managed model tools.

The following line sets the tool’s identifier.

    id = 'myorganization.shell.tools.source_code_editor'

The identifier is optional but we make use of it later when handling the application’s command line arguments. It doesn’t matter what the identifier is so long as it uniquely identifies a tool.

Our tool implements just one action of its own as shown below.

    line_nrs = Action(text="Show Line Numbers", checked=False,
            within='dip.ui.collections.view')

This is a checkable action that toggles the display of line numbers by the editor. Obviously in a real application the tool would implement many more actions.

A managed model tool is normally expected to handle more than one instance of a model. In other words when the user creates a new instance of a model it is added to the exsiting tool - a new tool is not created for it. This is not always possible or desireable. For example an existing tool may be being reused and doesn’t support multiple model instances, or, as in this case, we want to keep the behaviour of our application as simple as a possible. This behaviour is determined by the model_policy attribute which we set as shown below.

    model_policy = 'one'

The default value is many. By setting it to one the model manager will ensure that the tool always has exactly one model to handle. It will automatically create a new model when the tool is created. If the user invokes either the New or Open actions then the existing model is closed. If the existing model has been modified then the user will be asked if they want to save it or to discard the changes.

The model_policy attribute may also be set to zero_or_one. This is similar to one except that the model manager does not automatically create a new model when the tool is created.

We now come one to the part of the tool that creates the views that visualise a model.


    def create_views(self, model):
        """ Invoked to create the views for a model. """

        # We have chosen to use the editor widget to hold the code so the view
        # is actually in the model.
        view = model.editor

        # Configure the view according to the state of the action.
        self._configure_line_nrs(view)

        return [view]

As we have chosen to use the editor widget to store the text of the code then we simply return a one element list containing the editor. Before returning it we configure its use of line numbers according to the current state of the action.

The handles() method, shown below, is automatically invoked by the model manager to determine if the tool can handle a particular model.


    def handles(self, model):
        """ Check that the tool can handle the model. """

        # Because the application only has one type of model we could just
        # return True.
        return isinstance(model, PythonCode)

The following method is invoked when the action is triggered. It simply updates the editor widget’s use of line numbers according to the new state of the action.


    @line_nrs.triggered
    def line_nrs(self):
        """ Invoked when the line number action is triggered. """

        self._configure_line_nrs(self.current_view)

Finally we have the internal method, shown below, that actually does the work of configuring the use of line numbers by the editor widget.


    def _configure_line_nrs(self, view):
        """ Configure the state of line numbers for a view depending on the
        state of the line number action.
        """

        # Line numbers are configured by setting the width of the first margin.
        margin_width = 30 if self.line_nrs.checked else 0

        unadapted(view).setMarginWidth(0, margin_width)


We said earlier that we would be using dip’s UnicodeCodec to decode and encode our model as a UTF-8 byte stream. The line below creates an instance of the codec and adds it to our application.

IoManager.codecs.append(UnicodeCodec(format='myorganization.formats.python_code'))

The codec’s format matches the native format of our model.

We also need to tell our application that we want support for storing our model in the local filesystem. This is done by adding the appropriate storage factory as shown below.

IoManager.storage_factories.append(FilesystemStorageFactory())

Now we move on to the declaration of the shell, shown below.

view_factory = MainWindowShell(main_area_policy='single',
        tool_factories=[
                DirtyTool,
                lambda: ModelManagerTool(model_factories=[PythonCode]),
                PythonCodeEditorTool,
                QuitTool],
        window_title_template="[view][*]")

The first thing to note is the setting of the main_area_policy attribute to single. This determines how the main area of the shell is visualised and we are specifying that the main area will only ever contain one view. We can say this because we only have one tool and that tool’s model_policy attribute is set to one. If we didn’t set the main area policy then the default PyQt5 toolkit would place the view under a tab bar, which we don’t want.

The next thing to look at is the list of tool factories.

Most platforms provide some mechanism for indicating to the user that an application has unsaved data. In dip this is enabled by specifying the [*] marker in the shell’s window_title attribute. DirtyTool is a tool that automatically monitors other tools (specifically those that implement the IDirty interface) and updates the application’s overall state appropriately.

Tool factories are just Python callables that are invoked without arguments. As we want to pass arguments to the ModelManagerTool we are creating then we wrap it in a lambda function.

The last part of the shell’s declaration is the setting of the window_title_template attribute. The shell’s window_title attribute is updated from this template whenever the current view changes. The [*] marker is passed to the window title unchanged. The [view] marker is replaced by the name of the current view. By default the name of a view created by a managed model tool is the name of the model being visualised by the view, which (again by default) is the location where the model is stored. Therefore, in our example and if the Python code is stored in the filesystem, the [view] marker will be replaced by the name of the Python file.

Note

In many places dip will display the name of an object (e.g. the name of a tool or a model or a storage location). Normally dip will see if the object implements or can be adapted to the IDisplay interface and, if so, uses its name attribute. If this isn’t available then it will usually use the object’s string representation. If you find that you are seeing the standard Python string representation of an object (e.g. <foo.Foo object at 0xdeadbeef>) then try providing an adapter between the type of your object and the IDisplay interface.

The final part of this example we will look at is the handling of the command line arguments shown below.

if len(sys.argv) > 1:
    IShell(view).open('myorganization.shell.tools.source_code_editor',
            sys.argv[1], 'myorganization.formats.python_code')

If a command line argument is given then it is assumed to be a storage location where some Python code can be read from. The location is passed to the shell along with the native format of the Python code and the identifier of the tool that we wish to use. The result is that the code is read and displayed in an editor.

More on the Model Manager

In the previous example we took many shortcuts because we knew that we would only have one tool and one type of model. While convenient, those assumptions make it very difficult to reuse the tool and model in a different context. In this section we will extend the example to turn it into the basis for an IDE. (Actually, all we will do is to add a new tool and type of model but it will invalidate all the assumptions we made previously.)

In the updated example we will add the concept of a project. A project will have some metadata (e.g. a name and description) and a list of storage locations containing the Python code that makes up the project. A project will be stored as XML. We will provide a tool to allow a project to be created and updated.

We will now work through the significant changes. The complete example can be downloaded from here.

As we will now have more than one type of model the model manager will need to ask the user to choose between them, for example when the user invokes the New action. To do this the model manager will display a list of the model factories that it knows about. If a model factory implements the IDisplay interface then its name attribute is used, otherwise the factory’s string representation is used which, in our case, would be class '__main__.PythonCode'>. So, rather than just use the PythconCode class as our model factory, we create the following factory class.



@implements(IDisplay)
class PythonCodeFactory(Model):
    """ A PythonCode factory that implements the IDisplay interface. """

    # The model type name used in model manager dialogs and wizards.
    name = "Python code"

    def __call__(self):
        """ Invoked by the model manager to create the model instance. """

        return PythonCode()


An instance of this class will, when called without arguments, create an instance of the model, i.e. an instance of PythonCode. The factory instance also implements the IDisplay interface and so the model manager will use the string Python code whenever it displays the factory to the user.

We also want an instance of PythonCodeEditorTool to be displayed in a user friendly way by the model manager so we implement the IDisplay interface for it as well.

@implements(IManagedModelTool, IDisplay)

The next change we make is to the declaration of the action that toggles the line numbers. Because we may now have more than one type of view being displayed we only want the action to be enabled when the current view is an editor created by our PythonCodeEditorTool. Therefore we want the action to be disabled initially.

    line_nrs = Action(text="Show Line Numbers", checked=False, enabled=False,
            within='dip.ui.collections.view')

Next we provide the user friendly name required by the IDisplay interface.

    name = "Python source code editor"

We have also removed the setting of the model_policy attribute so that it reverts to its default value of many. This means that the tool will allow the user to edit several Python source code files at a time.

Of course now that the line number action is initially disable we need to enable it when a view created by the tool becomes current, and disable it when the view is no longer current. As shown below this is done by observing the current_view attribute of the ITool interface.


    @observe('current_view')
    def __current_view_changed(self, change):
        """ Invoked when the tool's current view changes. """

        self.line_nrs.enabled = (change.new is not None)

That completes the changes needed to our existing Python source code model and editor tool. We will now go through the new project model and editing tool which, naturally, follows the same pattern.

First we define our Project model as follows.

class Project(Model):
    """ The Project class encapsulates a project. """

    # The name of the project.
    name = Str()

    # A multi-line description of the project.
    description = Str()

    # The storage locations containing the source code that makes up the
    # project.
    contents = List(Str())

The next step is to create a factory class for our new model, just as we did before, as follows.



@implements(IDisplay)
class ProjectFactory(Model):
    """ A Project factory that implements the IDisplay interface. """

    # The model type name used in model manager dialogs and wizards.
    name = "Project"

    def __call__(self):
        """ Invoked my the model manager to create the model instance. """

        return Project()


Then we provide an adapter for our new model that adapts it to the IManagedModel interface as follows.



@adapt(Project, to=IManagedModel)
class ProjectIManagedModelAdapter(Adapter):
    """ Adapt Project to the IManagedModel interface. """

    # The native format.
    native_format = 'myorganization.formats.project'


The final step in adding our new model is to ensure that it can be read from and written to storage. As well as dip having built in support for models stored as Unicode byte streams it also has built in support for models stored as XML. This support is implemented by the XmlCodec codec and the IXmlDecoder and IXmlEncoder interfaces as follows.



@adapt(Project, to=[IFilterHints, IXmlDecoder, IXmlEncoder])
class ProjectCodecAdapter(Adapter):
    """ Adapt Project to the IFilterHints, IXmlDecoder and IXmlEncoder
    interfaces.
    """

    # The filter to use if the project is being stored in a file.
    filter = "Project files (*.prj)"


We now move on to the tool that will provide a view for editing our new Project model. The need for tools that edit instances of Model is, of course, a very common requirement and so dip has specific support in the form of the FormTool class. Its use is shown below.



@implements(IDisplay)
class ProjectEditorTool(FormTool):
    """ The ProjectEditorTool implements a tool for editing a project.  It
    leaves the management of the lifecycle of the model containing the code to
    a model manager.
    """

    # The tool's identifier.
    id = 'myorganization.shell.tools.project_editor'

    # The sub-class of Model that the tool can handle.
    model_type = Project

    # The project editor name used in model manager dialogs and wizards.
    name = "Project editor"

    @FormTool.view_factory.default
    def view_factory(self):
        """ Invoked to return the default view factory. """

        from dip.ui import Form, ListEditor, StorageLocationEditor, TextEditor

        return Form('name', TextEditor('description'),
                ListEditor('contents',
                        editor_factory=StorageLocationEditor(
                                filter_hints="Python source files (*.py *.pyw)",
                                format='myorganization.formats.python_code',
                                window_title="Choose project contents")),
                MessageArea())


First we define the tool’s identifier by declaring its id attribute. We then specify the type of model that the tool handles using the model_type attribute. Next is the user friendly name of the tool using the name attribute of the IDisplay interface. Finally we provide a decorated method that provides the default value of the view_factory attribute. It is here that we determine exactly what parts of the model can be edited (all of it in this case) and how the individual attribute editors are configured.

We next have to create the codec for our XML project and tell our application about it.

IoManager.codecs.append(XmlCodec(format='myorganization.formats.project'))

The next set of changes needed are to the definition of the shell itself. The updated definition is shown below.

view_factory = MainWindowShell(
        tool_factories=[
                DirtyTool,
                lambda: ModelManagerTool(
                        model_factories=[ProjectFactory(),
                                PythonCodeFactory()]),
                ProjectEditorTool,
                PythonCodeEditorTool,
                QuitTool],
        window_title="Python IDE[*]")

The first thing to note is that we have removed the setting of the main_area_policy attribute so that it reverts to its default value of many. This means that, with the default PyQt5 toolkit, each view will be placed in its own tab irrespective of how many views are displayed.

The next change is to the list of model factories set to the model manager’s model_factories attribute. Similarly we have added the new ProjectEditorTool to the shell’s tool_factories attribute.

The final change to the definition of the shell is the replacement of the use of the window_title_template attribute with the use of the window_title attribute. Because the name of each view is displayed elsewhere there is now no need to show it in the shell’s window title.

The very last change is the handling of any command line argument, shown below.

    IShell(ui).open('myorganization.shell.tools.project_editor', sys.argv[1],
            'myorganization.formats.project')

In the context of an IDE it is more sensible to assume that the command line argument refers to a project rather than an individual Python source code file.