The Cascading UI Wireframe

Introduction

How can we set up a project structure for a QML-heavy application that is easy to navigate for each team member, regardless of experience level? Something that can be intuitively understood by every contributor, including designers and product owners?

Also, how should we decide what goes into a QML module? And how many of those modules are needed?

What I propose as a possible answer to all of the above is a universal UI wireframe [1] that has often been rediscovered. When followed & applied correctly, everything else will fall into place.

Its roots can be traced back to at least the early 80s. It has proven to be useful to a wide range of (desktop) applications. Due to its ubiquitousness, users will find the look & feel familiar.

Here's how it looks, in its abstract form:

The Cascading UI Wireframe

The concept

I'd like to call this The Cascading UI Wireframe. Like steps in a waterfall, navigation flows left to right. Each pane's interactive state controls what's displayed to the right of it. Conversely, the larger the impact of an action on the UI, the further the user has to move to the left to initiate it.

A tree view navigating a directory structure for instance would affect what's shown in the details pane to the right.

Examples

Microsoft's Outlook illustrates the concept well:

Microsoft Outlook

Often the global pane is a collection of tool buttons or shortcuts that switch between different views. This isn't necessarily a violation, as long as interactions in one pane do not replace the contents of the pane to the left of it (or more precisely, the contents of a previous node in the navigation path).

The file explorer on macOS ("Finder"), when switched to column view, offers a modern implementation, with the details pane showing a preview & metadata of the selected file:

macOS Finder

Smalltalk's System Browser is perhaps the earliest & most principled example, but with the details pane moved below the navigation panes. Can you sense the similarities to macOS Finder's UI [2]?

Smalltalk System Browser

The navigation path rarely flows in a straight line. In the System Browser, the details pane is below the others. Yet the cascading effect is still recognisable.

Here's a typical IDE, with two details panes showing source code:

QtCreator

Tab views & pull-down buttons [3] are used to arrange stacks of pages within the same pane, making efficient use of the available screen estate.

Benefits of the Cascading UI Wireframe

  • A familiar user experience with bootstrapped design, at reduced costs.
  • Proven UI layouts for a wide range of (desktop) applications.
  • Easy to extend & to accommodate new features.
  • Panes can evolve independently.

To illustrate the benefits we'll build a simplified email client in QML: We parse emails from the file system, show the inbox and display the currently opened email. Full email datasets can be found on the internet; I'll be using a dataset from 2002 that I found on Kaggle [4]. This is how it looks:

Our mailer

Here's a video clip to demonstrate the implemented (and the missing) behaviour:

Video 1: Navigating & reading emails

A layered architecture for the presentation layer

The verticality derived from the semantics of our UI wireframe also informs our software design. Each cascading step can be mapped to a layer of UI components.

At the top of the hierarchy, we'll find our ApplicationWindow (in Main.qml), housing the application menu, an optional footer & our main layout, the UI Canvas.

The first layer

Our layers are organised as a flat list of directories within MyApp/UI.

How the first layer looks in the application

How the application looks when limited to Layer 1: The Canvas remains empty.

Each layer only uses components from the layers below, or, even stricter, from the next layer beneath. If we want to use a sibling component from the same layer, we first have to resolve the conflict by moving the component A) to a layer below, B) its own layer, or C) by moving the using component up. Thus, each layer conflict is resolvable.

Enforcing this rule is what makes our software design a layered system. Here's the essential definition [5]:

In a layered system, each layer:

  1. Depends on the layers beneath it;
  2. Is independent of the layers on top of it, having no knowledge of the layers using it.

We already understand how this will lead to tidier QML components & decreased coupling. The first point makes us factor out even small QML bits into their own components. This helps to avoid layer access violations. The second point requires us to be explicit about a component's dependencies. In general, this becomes simpler the smaller the components are, so there is a direct synergy with the first point. Thinking in terms of UI layers greatly helps us to decide when & where to split components.

Each layer maps to one QML module with a dedicated qmldir file, so even with many small QML components, our project structure won't feel crowded.

In the following code listing, the import directive for Layer1 introduces a shortened namespace, L1. Both components from that layer, AppMenu and Canvas that are used by Main are prefixed accordingly:

import MyApp.UI.Layer1 as L1

// Main.qml
ApplicationWindow {
    title: "QML Hierarchies"
    width: 1280
    height: 800
    visible: true
    menuBar: L1.AppMenu {}

    L1.Canvas { anchors.fill: parent }
}

Let's drill down further into MyApp.UI.Layer1.Canvas. Replacing the dots with slashes and adding the .qml suffix yields the path & file name, MyApp/UI/Layer1/Canvas.qml:

import MyApp.UI.Layer2 as L2

// Layer1/Canvas.qml
Container {
    id: root

    contentItem: RowLayout { spacing: 0 }

    L2.ToolBar {
        Layout.fillHeight: true
        topPadding: 8
    }
    L2.ContentPane {
        Layout.fillHeight: true
        Layout.fillWidth: true
    }
}

Already we notice the shortness of the file. This is intentional as we follow a stricter separation of concerns. Next stop is MyApp.UI.Layer2.ContentPane:

import MyApp.UI.Layer3 as L3

// Layer2/ContentPane.qml
Control {
    id: root

    contentItem: SplitView {
        Column {
            SplitView.preferredWidth: root.contentItem.width * .35

            L3.InboxView {
                id: inbox

                width: parent.width
                height: parent.height - parent.spacing - status.height
            }
            L3.InboxStatus {
                id: status

                width: parent.width
                messageIndex: inbox.currentIndex
                messageCount: inbox.count
            }
        }
        L3.MessageView {
            model: inbox.model
            currentIndex: inbox.currentIndex
        }
    }
}

From the names alone, we know that we are getting closer to the core of business logic. Whereas the previous component names felt generic, InboxView, InboxStatus & MessageView provide us with concrete hints on what to expect. The inbox is implemented as a common ListView, which, if done right, offers keyboard navigation out of the box.

import MyApp.Backend as BE
import MyApp.UI.Layer4 as L4

// Layer3/InboxView.qml
Control {
    id: root

    readonly property alias model: view.model
    readonly property alias currentIndex: view.currentIndex
    readonly property alias count: view.count

    padding: 8
    contentItem: ListView {
        id: view

        model: BE.InboxModel { directory: "file:assets/inbox" }
        clip: true
        focus: true
        highlight: L4.ItemHighlight {}
        highlightMoveDuration: 120
        delegate: ItemDelegate {
            required property int index
            required property var modelData

            width: view.width
            action: Action {
                onTriggered: {
                    view.currentIndex = index;
                    // Allow the user to refocus our ListView by clicking on a delegate.
                    // Focus can be lost through tab navigation, for instance.
                    view.focus = true;
                }
            }
            contentItem: L4.MessageHeading {
                message: parent.modelData
                bold: parent.ListView.isCurrentItem
            }
        }
        ScrollBar.vertical: ScrollBar {}
    }
}

That's it, that's all there is to our inbox! The model, currentIndex & count ListView properties are depended on by other Layer3 components. They are wired together in a Layer2 component, the previously shown ContentPane, thus ensuring layer access integrity.

Notice how we wrapped the actual delegate item, L4.MessageHeading, with an ItemDelegate. This allows us to bake ListView-specific behaviour into the InboxView instead of spreading it into L4.MessageHeading, thus keeping it highly reusable.

Even a small detail such as factoring out the highlight item into L4.ItemHighlight improves readability of our ListView implementation. As soon as the amount of details in our InboxView drops below a certain threshold, the source code becomes trivial to read.

All layers

All four layers in expanded view. Each layer forms its own module, with a dedicated qmldir.

The inbox model is a QAbstractItemModel, implemented in Python. It reads emails through Python's email module [6] which does all the parsing for us. Each message's headers and payload is then mapped to custom Qt::ItemDataRole's before being transported to the UI as needed [7].

To speed up parsing we use Python's own ProcessPoolExecutor. This also means we can launch the application sooner since the heavy lifting has been dispatched to a task queue.

Once parsed, the results are copied a couple messages at a time into the model that is exposed to InboxView. To keep the UI responsive, we copy in a periodically invoked slot (Qtimer.singleShot). For about 6300 emails, this takes less than a second on a MacBook Pro M1.

@QmlElement
class InboxModel(QAbstractItemModel):
    ...

    def append_parsed_messages_from_task_queue(self):
        try:
            begin = len(self._messages)
            next_messages = next(self._pending_messages)
            end = begin + len(next_messages)

            self.beginInsertRows(QModelIndex(), begin, end - 1)
            self._messages.extend(next_messages)
            self.endInsertRows()

            QTimer.singleShot(0, self.append_parsed_messages_from_task_queue)
        except StopIteration:
            pass

    @Property(QUrl)
    def directory(self):
        return self._directory

    @directory.setter
    def directory(self, url: QUrl):
        ...

        with ProcessPoolExecutor(max_workers=8) as executor:
            files = chunk([fn for fn in path.iterdir()], 64)
            self._pending_messages = executor.map(parse_emails, files)

        self.append_parsed_messages_from_task_queue()

    ...

In total, our toy example tops out at around 500-600 lines of code, Python & QML combined. The QML parts and how they are organised might serve as an inspiration for your next project. As usual, you'll find the complete example on GitHub [8].