Windows & Dialogs

Subclassing QMainWindow

This first task involves the creation of a small but useful image viewing application that demonstrates the most important features of QMainWindow. It is bigger than anything you’ve implemented thus far in these exercises and should give you a good feel for what is involved in building a fully-featured desktop application using Qt.

Screenshot of an image viewing application

Widgets & Status Bar

  1. Create a directory for this exercise, download viewer.zip into it, then unzip the archive. This should give you a subdirectory viewer, containing

    • A CMake build file
    • Source files viewer.hpp, viewer.cpp, main.cpp
    • Subdirectory images, containing a few JPEG images1

    Open viewer.hpp in a text editor and examine it. This file defines a class named ImageViewer that inherits from QMainWindow. If you look at the corresponding implementation file, viewer.cpp, you will see that the methods of ImageViewer are mostly empty, but enough has been implemented that you can run the application.

    Use CMake to build the application in the normal way, then run it with ./viewer. You should see a blank window appear.

  2. Complete the createWidgets() method by adding the following code to it:

    imageWidget = new QLabel();
    imageWidget->setScaledContents(true);
    
    QScrollArea* view = new QScrollArea();
    view->setBackgroundRole(QPalette::Dark);
    view->setWidget(imageWidget);
    
    setCentralWidget(view);

    QMainWindow uses the notion of a central widget, occupying the main area of the window. This code defines that central widget to be a QScrollArea widget, containing a QLabel widget. An image will be associated with the latter.

    Rebuild the application and run it again to check that it still creates a blank window.

  3. Add a status bar to the application by putting the following code in the createStatusBar() method:

    fileInfo = new QLabel();
    zoomInfo = new QLabel("x1");
    
    QStatusBar* status = statusBar();
    status->addWidget(fileInfo);
    status->addPermanentWidget(zoomInfo);

    This code gives the window a status bar containing two QLabel widgets. One of them, fileInfo, will be used to show the filename of the currently loaded image. The other, zoomInfo, will be used to show the current magnification factor at which the image is displayed.

    The latter is added as a ‘permanent widget’, meaning that it will be positioned at the far right of the status bar and will always be visible. The filename, on the other hand, will appear on the left and can be temporarily hidden—e.g., by ‘status tips’ for menu entries.

    Rebuild the application and run it again. You should see a status bar at the bottom of the window now, with zoomInfo’s default text of x1 visible on the right.

File Menu

The File menu is the first of three menus that will be added to this application. In each case, the menu creation process involves creating actions, configuring those actions, and connecting each action to one or more slots that will handle the action. The actions are then added to the menu.

  1. The File menu will have two actions: one to open a new image file and the other to close the application. To create and configure these two actions, put the following lines in the addFileMenu() method:

    QAction* openAction = new QAction("&Open...", this);
    openAction->setShortcut(QKeySequence::Open);
    openAction->setStatusTip("Open an image file");
    connect(openAction, SIGNAL(triggered()), this, SLOT(openImage()));
    
    QAction* closeAction = new QAction("Quit", this);
    closeAction->setShortcut(QKeySequence::Close);
    closeAction->setStatusTip("Quit the application");
    connect(closeAction, SIGNAL(triggered()), this, SLOT(close()));

    Notice the pattern for creating and configuring a QAction object. When creating the action, you must specify the text that will be used to describe the action in the menu, and this must be followed by a reference to the parent of the action: the QMainWindow object itself.

    After the action is created, you can make method calls to specify the keyboard shortcut for the action and the tip that will be displayed in the status bar when the action has focus. Both of these are optional steps, but they help to improve usability.

    Tip

    If you are specifying a keyboard shortcut then note that you can also use & in the action text to highlight that the following character is used as the keyboard shortcut.

    Lastly, you should connect the action’s triggered() signal to the slot that will handle the task represented by the action. In the code above, the ‘Open’ action is connected to a custom slot named openImage(), whereas the ‘Quit’ action is connected to the built-in slot close(), inherited from QMainWindow.

  2. The final step is to create the menu itself and add the actions to it. To do this, add the following code to the addFileMenu() method:

    QMenu* fileMenu = menuBar()->addMenu("&File");
    fileMenu->addAction(openAction);
    fileMenu->addAction(closeAction);
  3. Rebuild the application and run it. It will now have a File menu. When you interact with it, you should see something similar to the screenshot below.

    Note how the status tip appears in the status bar. Choosing the ‘Open’ action from the menu will do nothing at the moment because the slot to which it has been connected is currently a stub, but you should find that selecting ‘Quit’ will close the window.

View Menu

Using the File menu code just discussed as a guide, write similar code to create a View menu, in the addViewMenu() method.

  1. Create QAction objects referenced by pointers zoomInAction and zoomOutAction. Specify menu entry text of "Zoom in" and "Zoom out" for the two actions. Specify keyboard shortcuts of QKeySequence::ZoomIn and QKeySequence::ZoomOut, likewise. Provide some appropriate text as a status tip for each action, then connect the actions to the zoomIn() and zoomOut() slots.

  2. After creating the actions, write the code to add those actions to the menu. Use "&View" as the menu title, the & here indicating that the keyboard shortcut for the menu itself will use V.

    Rebuild the application to check that you’ve not made any mistakes.

Help Menu

  1. For the last of the three menus, add the following code to the addHelpMenu() method:

    QAction* aboutAction = new QAction("&About", this);
    aboutAction->setStatusTip("Show information about this application");
    connect(aboutAction, SIGNAL(triggered()), this, SLOT(about()));
    
    QAction* aboutQtAction = new QAction("About &Qt", this);
    aboutQtAction->setStatusTip("Show information about the Qt library");
    connect(aboutQtAction, SIGNAL(triggered()), qApp, SLOT(aboutQt()));

    These actions will be used to display help relating to the application. In the second of these, qApp is a global variable referencing the QApplication object used by the application, and aboutQt() is a standard slot in QApplication that displays a dialog containing information about Qt itself.

  2. Now write the code to add these actions to the menu. Use "&Help" as the menu title.

    Rebuild the application. When you run it, you should see that it now has three menus. Although the File and View menus are not yet fully functional, you should find that the actions in the Help menu will display suitable dialogs when triggered.

Final Steps

  1. ImageViewer has two helper methods named updateStatus() and setDisplayedSize() that are needed by its slots. The first is used to update the status bar with the current image zoom factor and the second is used to resize the label holding the image, according to the current image zoom factor. Add code to these methods now so that they look like this:

    void ImageViewer::updateStatus()
    {
      QString zoomText = "x" + QString::number(zoomFactor);
      zoomInfo->setText(zoomText);
    }
    
    void ImageViewer::setDisplayedSize()
    {
      imageWidget->resize(zoomFactor*image.width(), zoomFactor*image.height());
    }
  2. The penultimate step is to finish the implementation of the openImage() slot. Add the following code to this method:

    QString path = QFileDialog::getOpenFileName(
      this, "Open Image", ".", "Image files (*.jpg *.png)");
    
    if (path.size() > 0) {
      image.load(path);
      imageWidget->setPixmap(image);
    
      fileInfo->setText(QFileInfo(path).fileName());
      zoomFactor = 1;
      setDisplayedSize();
      updateStatus();
    }

    This code begins with a call to the getOpenFileName() function of standard dialog class QFileDialog. This will open a standard dialog for selecting an image file. The string arguments to this call are the dialog’s title, the initial directory displayed by the dialog (the current directory, in this case) and a filter that restricts displayed files to those with .jpg and .png filename extensions.

    If the user selects an image file, the path variable will be a non-empty string and the if statement body will run. This will load and display the image, and also update the status bar with the name of the image file.

    Rebuild and run the application now and try opening one of the provided image files. Notice how the dialog only shows these files, and how it displays small thumbnails beside their names.

  3. Finally, we need to add support for zooming in and out of an image. Find the zoomIn() slot and add the the following code to it:

    if (image.width() > 0 and zoomFactor < MAX_ZOOM) {
      zoomFactor++;
      setDisplayedSize();
      updateStatus();
    }

    Write something similar for the body of the zoomOut() slot. (Here, you will need to ensure that zoomFactor is limited to a minimum value of 1.)

    Rebuild the application and run it again. Open an image file and try zooming in and out, using the menu actions and the corresponding keyboard shortcuts.

Subclassing QDialog

Many GUIs combine a main application window with smaller windows know as dialogs. The main window is always visible, but the dialogs tend to be more transient, appearing for a short time to provide information or allow the user to perform a particular task. Dismissing a dialog will not affect the main window, whereas closing the main window will also destroy any dialogs associated with it.

Let’s add a simple dialog to the image viewer application. This dialog will provide some information about the currently-loaded image.

The InfoDialog Class

  1. In the same directory as viewer.hpp, create a new header file named info.hpp, containing the following:

    #pragma once
    
    #include <QDialog>
    
    class QPushButton;
    class QTextEdit;
    
    1class InfoDialog: public QDialog
    {
      public:
    3    InfoDialog(QWidget*);
    4    void updateInfo(const QPixmap&);
    
      private:
    2    QTextEdit* info;
        QPushButton* closeButton;
        void createWidgets();
        void arrangeWidgets();
    };
    1
    InfoDialog is a subclass of QDialog, Qt’s generic dialog class.
    2
    This dialog contains only two widgets: a QTextEdit widget, which will be used (in read-only mode) to display the information; and a button, which will be used to dismiss the dialog.
    3
    When an InfoDialog is created, it must be supplied with a pointer to its parent, the main application window that effectively owns it.
    4
    The updateInfo() method will be called by the application’s main window. The latter will supply a reference to the currently-displayed image. The method will extract information from the image and use it to update what is displayed by the dialog.
  2. Now create the implementation file for the dialog, info.cpp. Begin by writing the #include directives and the constructor:

    #include <QtWidgets>
    #include "info.hpp"
    
    InfoDialog::InfoDialog(QWidget* parent): QDialog(parent)
    {
      createWidgets();
      arrangeWidgets();
      setWindowTitle("Image Info");
    }

    This follows the pattern seen in earlier exercises, delegating the work of widget creation and layout to private helper methods.

    Now add those methods:

    void InfoDialog::createWidgets()
    {
      info = new QTextEdit();
    1  info->setReadOnly(true);
    
      closeButton = new QPushButton("Close");
    2  connect(closeButton, SIGNAL(clicked()), this, SLOT(close()));
    }
    
    void InfoDialog::arrangeWidgets()
    {
      QVBoxLayout* box = new QVBoxLayout();
      box->addWidget(info);
      box->addWidget(closeButton);
      setLayout(box);
    }
    1
    QTextEdit is used solely for display, so we disable editing.
    2
    Connecting the clicked() signal to the close() slot inherited from QDialog means that button clicks will dismiss the dialog.
  3. The last piece of code needed in InfoDialog is an implementation of the updateInfo() method:

    void InfoDialog::updateInfo(const QPixmap& image)
    {
    1  QString text("No image loaded!");
    
    2  if (image.width() > 0) {
    3    text = QString("Width = %1\nHeight = %2\n")
          .arg(image.width())
          .arg(image.height());
      }
    
      info->setText(text);
    }
    1
    The default message displayed by the dialog will be that there is no image!
    2
    If the pixmap has a width greater than 0 then an image must exist, so we can replace the default message with actual information about that image.
    3
    Qt’s QString class allows you to replace placeholders like %1, %2 with values supplied via the arg() method.
  4. Finally, modify CMakeLists.txt, adding a dependency on info.cpp. You will need to alter the call to the qt_add_executable() function so that it looks like this:

    qt_add_executable(viewer
        main.cpp
        viewer.cpp
        info.cpp
    )

    Save your changes and rerun CMake to update the makefile, then run the build to check that you’ve not made any mistakes:

    cmake ..
    make

Changes to ImageViewer

  1. Make the following three changes to the definition of ImageViewer in viewer.hpp:

    • Add a forward reference to the InfoDialog class, underneath the other forward references:

      class InfoDialog;
    • Add a new field to the private section of the class definition, representing the dialog:

      InfoDialog* infoDialog;
    • Declare a new slot in the private slots section:

      void showInfo();
  2. Now turn your attention to viewer.cpp.

    • Add a #include directive for info.hpp to the top of the file.

    • Modify the constructor so that it make the infoDialog field a null pointer, in the constructor’s initializer list:

      ImageViewer::ImageViewer():
        QMainWindow(), zoomFactor(1), infoDialog(nullptr)
      {
        ...
      }
    • Modify addViewMenu() so that it creates an action that will open the dialog. These lines will need to be added:

      QAction* infoAction = new QAction("Info", this);
      infoAction->setStatusTip("Displays some basic info on this image");
      connect(infoAction, SIGNAL(triggered()), this, SLOT(showInfo()));
      ...
      viewMenu->addAction(infoAction);

      (Note: the ellipsis above represents code from the method that isn’t shown here)

  3. Implement the showInfo() slot in viewer.cpp:

    void ImageViewer::showInfo()
    {
    1  if (infoDialog == nullptr) {
        infoDialog = new InfoDialog(this);
      }
    
      infoDialog->updateInfo(image);
    
    2  infoDialog->show();
    3  infoDialog->raise();
    4  infoDialog->activateWindow();
    }
    1
    If infoDialog is null then the dialog hasn’t been created yet, so we will need to create it.
    2
    Invoking show() will make the dialog visible if necessary.
    3
    Invoking raise() will move the dialog above any other windows that may be covering it.
    4
    Invoking activateWindow() will grant focus to the dialog.
  4. Rebuild and run the application. Try activating the dialog when no image is loaded, then try doing the same after loading an image. You should see something like this appear:

    Screenshot of the image information dialog

    What happens if you open a new image file while the dialog is on screen?

Fixing The Problem

There are two ways of fixing the problem.

One way would be to make the dialog modal. A modal dialog seizes focus and prevents the user from interacting with any other part of the application until the dialog has been dismissed. To implement this new behaviour, you need to replace the lines invoking the show(), raise() and activateWindow() methods with a line that invokes the exec() method:

infoDialog->exec();

The other approach would be to add code to the openImage() method that invokes updateInfo() on the dialog if it exists and is currently visible:

if (infoDialog != nullptr and infoDialog->isVisible()) {
  infoDialog->updateInfo(image);
}

Apply one of these fixes, then rebuild the application and run it to check that the fix has worked.

Footnotes

  1. These are images from the Rosetta, Dawn and New Horizons spacecraft.↩︎