Multiple Document Interface MDI

MDI offers many benefits compared with SDI or with running multiple application instances. MDI applications are less resource-hungry, and they make it much easier to offer the user the ability to lay out their document windows in relation to each other. One drawback, however, is that you cannot switch between MDI windows using Alt+Tab (Command+Tab on Mac OS X), although this is rarely a problem in practice since for MDI applications, programmers invariably implement a Window menu for navigating between windows.

The key to creating MDI applications is to create a widget subclass that handles everything itself, including loading, saving, and cleanup, with the application holding these widgets in an MDI "workspace", and passing on to them any widget-specific actions.

In this section, we will create a text editor that offers the same kind of functionality as the SDI Text Editor from the preceding section, except that this time we will make it an MDI application. The application is shown in Figure 9.8.

Each document is presented and edited using an instance of a custom TextEdit widget, a QTextEdit subclass. The widget has the Qt.WA_DeleteOnClose attribute set, has a filename instance variable, and loads and saves the filename it is given. If the widget is closed (and therefore deleted), its close event handler gives the user the opportunity to save any unsaved changes. The TextEdit implementation is straightforward, and it is quite similar to code we have seen before, so we will not review it here; its source code is in the module chap09/textedit.py.

The code for the application proper is in the file chap09/texteditor.pyw. We will review the code for this, starting with some extracts from the MainWindow subclass's initializer.

class MainWindow(QMainWindow):

def_init_(self, parent=None):

super(MainWindow, self)._init_(parent)

self.mdi = QWorkspace() self.setCentralWidget(self.mdi)

PyQt's MDI widget is called QWorkspace* Like a tab widget or a stacked widget, a QWorkspace can have widgets added to it. These widgets are laid out by the workspace, rather like a miniature desktop, with the widgets tiled, cascaded, iconized, or dragged and resized by the user within the workspace's area.

★From Qt 4.3, MDI is provided by the QMdiArea class with an API similar to QWorkspace.

Qworkspace Widget
Figure 9.8 MDI Text Editor with four documents

It is possible to have a workspace that is larger than its window by calling QWorkspace.setScrollBarsEnabled(True).The workspace's background can be set by specifying a background brush.

fileNewAction = self.createAction("&New", self.fileNew,

QKeySequence.New, "filenew", "Create a text file")

Most of the file actions are created as we have seen before. But as we will see, the MDI editor, like the SDI editor, does not have an okToContinue() method because each document window takes care of itself.

fileQuitAction = self.createAction("&Quit", self.close, "Ctrl+Q", "filequit", "Close the application")

If we close the application's window, the application will terminate. All the document windows will be closed, and any with unsaved changes are responsible for prompting the user and saving if asked to do so.

editCopyAction = self.createAction("&Copy", self.editCopy, QKeySequence.Copy, "editcopy", "Copy text to the clipboard")

In the SDI editor we passed on the "copy", "cut", and "paste" actions to each window's QTextEdit to handle. This is not possible in the MDI application because when the user triggers one of these actions, it must be applied to whichever

TextEdit window is active. For this reason the main window must do some work itself, as we will see when we review the implementation of these actions.

We have not shown the code for the other file and edit actions, because they all follow the same pattern as those shown earlier.

self.windowNextAction = self.createAction("&Next", self.mdi.activateNextWindow, QKeySequence.NextChild) self.windowPrevAction = self.createAction("&Previous", self.mdi.activatePreviousWindow, QKeySequence.PreviousChild) self.windowCascadeAction = self.createAction("Casca&de", self.mdi.cascade) self.windowTileAction = self.createAction("&Tile", self.mdi.tile)

self.windowRestoreAction = self.createAction("&Restore All", self.windowRestoreAll) self.windowMinimizeAction = self.createAction("&Iconize All", self.windowMinimizeAll) self.windowArrangeIconsAction = self.createAction(

"&Arrange Icons", self.mdi.arrangeIcons) self.windowCloseAction = self.createAction("&Close", self.mdi.closeActiveWindow, QKeySequence.Close)

All the window actions are created as instance variables because we will be accessing them in another method. For some of the actions we can pass the work directly onto the mdi workspace instance, but minimizing and restoring all the MDI windows we must handle ourselves.

self.windowMapper = QSignalMapper(self) self.connect(self.windowMapper, SIGNAL("mapped(QWidget*)"), self.mdi, SLOT("setActiveWindow(QWidget*)"))

In the Window menu that we will create, we need some way of making the window that the user chooses the active window. We saw a very simple solution to this problem in the preceding section. Another approach is to use partial function application, connecting each window action to QWorkspace.setActiveWindow() with the relevant TextEdit as argument. Here we have taken a pure PyQt approach, and we have used the QSignalMapper class. We will explain its use when we review the updateWindowMenu() method.

self.windowMenu = self.menuBar().addMenu("&Window") self.connect(self.windowMenu, SIGNAL("aboutToShow()"), self.updateWindowMenu)

The connection to aboutToShow() ensures that our updateWindowMenu() method is called before the menu is shown.

self.updateWindowMenu() self.setWindowTitle("Text Editor") QTimer.singleShot(0, self.loadFiles)

At the end of the constructor we call updateWindowMenu() to force the Window menu to be created. This may seem strange; after all, it will be created anyway when the user tries to use it, so why do so now? The reason is that if we automatically load in some documents at startup, the user might want to navigate between them using our keyboard shortcuts (F6 and Shift+F6), but the shortcuts will become active only after the menu has been created.

def closeEvent(self, event): failures = []

for textEdit in self.mdi.windowList(): if textEdit.isModified(): try:

textEdit.save() except IOError, e:

failures.append(str(e)) if failures and \

QMessageBox.warning(self, "Text Editor — Save Error", "Failed to save%s\nQuit anyway?" % \ "\n\t".join(failures),

QMessageBox.Yes|QMessageBox.No) == QMessageBox.No: event.ignore() return settings = QSettings()

settings.setValue("MainWindow/Size", QVariant(self.size())) settings.setValue("MainWindow/Position",

QVariant(self.pos())) settings.setValue("MainWindow/State",

QVariant(self.saveState())) files = QStringList() for textEdit in self.mdi.windowList():

if not textEdit.filename.startsWith("Unnamed"): files.append(textEdit.filename) settings.setValue("CurrentFiles", QVariant(files)) self.mdi.closeAllWindows()

When the application is terminated we give the user the opportunity to save any unsaved changes. Then we save the main window's size, position, and state. We also save a list of all the filenames from all the MDI windows. At the end we call QWorkspace.closeAllWindows(), which will result in each window receiving a close event.

If any save fails, we take note, and after all the files have been processed, if there were errors we pop up a message box informing the user and give them the chance to cancel terminating the application.

In the TextEdit's close event there is code to give the user the chance to save any unsaved changes, but at this point there can't be any because we have already handled this by saving unsaved changes at the beginning of this method. We have the code in the TextEdit's close event because the user can close any window at any time, so each window must be able to cope with being closed. But we do not use this when the application is terminated, and instead call save() for modified files, because we want to keep a current files list, and to do that every file must have a proper filename before we reach the code for saving the current files list, and calling save() earlier achieves this.

def loadFiles(self):

for filename in sys.argv[1:31]: # Load at most 30 files filename = QString(filename) if QFileInfo(filename).isFile(): self.loadFile(filename) QApplication.processEvents()

else:

settings = QSettings()

files = settings.value("CurrentFiles").toStringList() for filename in files:

filename = QString(filename) if QFile.exists(filename): self.loadFile(filename) QApplication.processEvents()

We have designed this application so that it will load back all the files that were open the last time the application was run. However, if the user specifies one or more files on the command line, we ignore the previously opened files, and open just those the user has specified. In this case, we have chosen to arbitrarily limit the number of files to 30, to protect the user from inadvertently giving a file specification of *.* in a directory with hundreds or thousands of files.

The QApplication.processEvents() calls temporarily yield control to the event loop so that any events that have accumulated—such as paint events—can be handled. Then processing resumes from the next statement. The effect in this Doing application is that an editor window will pop up immediately after each file has been loaded, rather than the windows appearing only after all the files have been loaded. This makes it clear to the user that the application is doing some-Start- thing, whereas a long delay at the beginning might make the user think that Up the application has crashed. Another benefit of using processEvents() is that sidebar the user's mouse and keyboard events will get some processor time, keeping the 184 ■a application responsive even if a lot of other processing is taking place.

Using processEvents() to keep an application responsive during long-running operations is much easier than using threading. Nonetheless, this method must be used with care because it could lead to events being handled that cause problems for the long-running operations themselves. One way to help

Lots of Processing at reduce the risk is to pass extra parameters—for example, a flag that limits the kinds of events that should be processed, and a maximum time to be spent processing events. We will see another example of the use of processEvents() in Chapter 12; threading is the subject of Chapter 19.

def loadFile(self, filename):

textEdit = textedit.TextEdit(filename) try:

textEdit.load() except (IOError, OSError), e:

QMessageBox.warning(self, "Text Editor — Load Error",

"Failed to load %s: %s" % (filename, e)) textEdit.close() del textEdit else:

self.mdi.addWindow(textEdit) textEdit.show()

When a file is loaded, as a result of either loadFiles() or fileOpen(), it creates a new TextEdit, with the given filename, and tells the editor to load the file. If loading fails, the user is informed in a message box, and the editor is closed and deleted. If loading succeeds, the editor is added to the workspace and shown. We do not need a static instances variable to keep the TextEdit instances alive, since QWorkspace takes care of this automatically for us.

def fileNew(self):

textEdit = textedit.TextEdit()

self.mdi.addWindow(textEdit)

textEdit.show()

This method simply creates a new editor, adds it to the workspace, and shows it. The editor's window title will be "Unnamed-n.txt", where n is an incrementing integer starting from one. If the user types in any text and attempts to close or save the editor, they will be prompted to choose a proper filename.

def fileOpen(self):

filename = QFileDialog.getOpenFileName(self,

"Text Editor -- Open File") if not filename.isEmpty():

for textEdit in self.mdi.windowList(): if textEdit.filename == filename:

self.mdi.setActiveWindow(textEdit) break else:

self.loadFile(filename)

If the user chooses to open a file, we check to see whether it is already in one of the workspace's editors. If it is we simply make that editor's window the active window. Otherwise we load the file into a new editor window. If our users wanted to be able to load the same file more than once—for example, to look at different parts of a long file—we could simply call loadFile() every time and not bother to see whether the file is in an existing editor.

def fileSave(self):

textEdit = self.mdi.activeWindow()

if textEdit is None or not isinstance(textEdit, QTextEdit): return try:

textEdit.save() except (IOError, OSError), e:

QMessageBox.warning(self, "Text Editor -- Save Error",

"Failed to save %s: %s" % (textEdit.filename, e))

When the user triggers the "file save" action, we determine which file they want to save by calling QWorkspace.activeWindow(). If this returns a TextEdit, we call save() on it.

def fileSaveAll(self): errors = []

for textEdit in self.mdi.windowList(): if textEdit.isModified(): try:

textEdit.save() except (IOError, OSError), e:

errors.append("%s: %s" % (textEdit.filename, e))

if errors:

QMessageBox.warning(self, "Text Editor -- Save All Error", "Failed to save\n%s" % "\n".join(errors))

As a convenience, we have provided a "save all" action. Since there might be a lot of windows, and if there is a problem saving one (for example, lack of disk space), the problem might affect many windows. So instead of giving error messages when each save() fails, we accumulate the errors in a list and show them all at the end, if there are any to show.

def editCopy(self):

textEdit = self.mdi.activeWindow()

if textEdit is None or not isinstance(textEdit, QTextEdit): return cursor = textEdit.textCursor() text = cursor.selectedText() if not text.isEmpty():

clipboard = QApplication.clipboard() clipboard.setText(text)

This method starts in the same way the previous one did—and the same way all the methods that apply to one particular window start—by retrieving the editor that the user is working on. The QTextCursor returned by QTextEdit.text-Cursor() is a programmatic equivalent to the cursor the user uses, but it is independent of the user's cursor; this class is discussed more fully in Chapter 13. If there is selected text, we copy it to the system's global clipboard.*

def editCut(self):

textEdit = self.mdi.activeWindow()

if textEdit is None or not isinstance(textEdit, QTextEdit): return cursor = textEdit.textCursor() text = cursor.selectedText() if not text.isEmpty():

cursor.removeSelectedText() clipboard = QApplication.clipboard() clipboard.setText(text)

This method is almost the same as the copy method. The only difference is that if there is selected text, we remove it from the editor.

def editPaste(self):

textEdit = self.mdi.activeWindow()

if textEdit is None or not isinstance(textEdit, QTextEdit): return clipboard = QApplication.clipboard() textEdit.insertPlainText(clipboard.text())

If the clipboard has text, whether from a copy or cut operation in this application, or from another application, we insert it into the editor at the editor's current cursor position.

All the basic MDI window operations are provided by QWorkspace slots, so we do not need to provide tiling, cascading, or window navigation ourselves. But we do have to provide the code for minimizing and restoring all windows.

def windowRestoreAll(self):

for textEdit in self.mdi.windowList(): textEdit.showNormal()

The windowMinimizeAll() method (not shown) is the same, except that we call showMinimized() instead of showNormal().

A QSignalMapper object is one that emits a mapped() signal whenever its map() slot is called. The parameter it passes in its mapped() signal is the one that

★ X Window System users have two clipboards: the default one and the mouse selection one. Mac OS X also has a "Find" clipboard. PyQt provides access to all the available clipboards using an optional "mode" second argument to setText() and text().

was set to correspond with whichever QObject called the map() slot. We use a signal mapper to relate actions in the Window menu with TextEdit widgets so that when the user chooses a particular window, the appropriate TextEdit will become the active window. This is set up in two places: the form's initializer, and in the updateWindowMenu() method, and is illustrated in Figure 9.9.

sender parameter sender „Object.

sender parameter

(QObject,

QObject)

(QObject,

QWidget)

(QObject,

int)

SÏGNAL(mapped(QWidget)]

QSignalMapper

Figure 9.9 The general operation of a QSignalMapper

SÏGNAL(mapped(QWidget)]

QSignalMapper

Figure 9.9 The general operation of a QSignalMapper

In the form's initializer we made a signal-slot connection from the signal mapper's mapped(QWidget*) signal to the MDI workspace's setActiveWindow(QWidget*) slot. To use this, the signal mapper must emit a signal that corresponds to the MDI window the user has chosen from the Window menu, and this is set up in the updateWindowMenu() method. The MDI Text Editor's signal mapper is illustrated in Figure 9.10.

def updateWindowMenu(self): self.windowMenu.clear()

self.addActions(self.windowMenu, (self.windowNextAction, self.windowPrevAction, self.windowCascadeAction, self.windowTileAction, self.windowRestoreAction, self.windowMinimizeAction, self.windowArrangelconsAction, None, self.windowCloseAction)) textEdits = self.mdi.windowList() if not textEdits: return

We begin by clearing all the actions from the Window menu, and then we add back all the standard actions. Next, we get the list of TextEdit windows; if there are none we are finished and simply return; otherwise, we must add an entry for each window.

self.windowMenu.addSeparator() i = 1

menu = self.windowMenu for textEdit in textEdits:

self.windowMenu.addSeparator() menu = menu.addMenu("&More")

accel = "&%c " % chr(i + ord("@") - 9)

We iterate over all the windows. For the first nine, we create an "accel" string of &1, &2, and so on, to produce 1,2, • •, 9. If there are ten or more windows, we create a submenu with the text "More", and add the tenth and subsequent windows to this submenu. For the tenth to thirty-sixth windows, we create accel strings of &A, &B,.., &Z; for any other windows we do not provide an accel string. (The %c format string is used to specify a single character.) The More submenu's accelerators are English-specific; other languages may need different treatment.

action = menu.addAction("%s%s" % (accel, title)) self.connect(action, SIGNAL("triggered()"), self.windowMapper, SLOT("map()")) self.windowMapper.setMapping(action, textEdit) i += 1

We create a new action with the (possibly empty) accel text and the title text—the window's title, which is the filename without the path. Then we connect the action's triggered() signal to the signal mapper's map() slot. This means that whenever the user chooses a window from the Window menu, the signal mapper's map() slot will be called. Notice that neither the signal nor the slot has parameters; it is up to the signal mapper to figure out which action triggered it—it could use sender(), for example. After the signal-slot connection, we set up a mapping inside the signal mapper from the action to the corresponding TextEdit.

actionW

:SIGNAL(mapped(textEditW)) y

SLOT(mdi.activateWindow(textEditW))

Figure 9.10 The MDI Editor's signal mapper

When the signal mapper's map() slot is called, the signal mapper will find out which action called it, and use the mapping to determine which TextEdit to pass as a parameter. Then the signal mapper will emit its own mapped(QWidget*) signal, with the parameter. We connected the mapped() signal to the MDI

(action1,textEdit1) (action2,textEdit2)

workspace's setActiveWindow() slot, so this slot is in turn called, and the TextEdit passed as a parameter will become the active window.

That completes our review of the MDI Text Editor. We have skipped the code for creating the application object and the main window since it is the same as many code examples we have seen in previous examples.

Was this article helpful?

+5 -4
100 SEO Tips

100 SEO Tips

100 SEO Tips EVERY SEO Enthusiast Should Know. This Report 100 SEO Tips will help you to Utilize These Tips to Dominate The Search Engine Today.

Get My Free Ebook


Responses

  • ambrogino
    How to drag and drop in qmdiarea?
    8 years ago
  • pasi
    How to make a mdi in pyqt4 python 3?
    2 years ago
  • Negisti
    How to open a new file in mdi area pyqt5 with full example?
    1 year ago

Post a comment