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.