Skip to main content

Implementing a complex User Interface for the Daisy Patch

·2495 words·12 mins
Development Eurorack User Interface C++
Table of Contents
Building Digi Env - This article is part of a series.
Part 3: This Article

This post describes the implementation of the user interface we designed in the first article of the series with the tools described in the second article of the series. To implement the user interface we make use of our interpretation of the Model - View - Controller (MVC) pattern described in the next section.

Model - View - Controller
#

Model - View - Controller (MVC) is a pattern to implement graphical user interfaces. MVC has quite a long history and was interpreted differently by different authors and teams, but generally it describes the use of a Model that defines and stores the data for an application, a View that provides a user a way to look at the Model in different representations and a Controller that provides a way to interact with the data in the Model.

In the specific interpretation of MVC that is used to implement the user interface, we have a quite powerful View with sub views that display different data of the Model. The View keeps track of which sub views are currently active and processes user input. If the user indicates that he wants to change data, the View forwards the request to change data to the Controller. In contrast, the View reads the data to display directly from the Model.

Changes of the data in the application therefore always go through the Controller.

The relationship between Model, View and Controller is depicted below.

graph LR; A[Controller] -->|updates| B[Model] C[View] -->|uses| A C -->|reads| B

Implementation
#

This sections describes the implementation of the user interface in detail. First it describes the implementation of the model, and afterward the implementation of the controller. Both implementations are pretty simple at the current state and are mostly implemented to implement the full MVC pattern prototypically to be further enhanced once we add application functionality.

And finally we describe the view, which at this stage is the most complicated of the classes and implements quite a bit of logic to interpret the user’s input as well as the drawing functionality necessary to draw the basic user interface.

The Model
#

The purpose of the model is fundamentally to provide a central place to store the data of the application. Therefore, the model class stores data for each of the envelopes and some additional data to facilitate the navigation. It provides access to the data using getter and setters. The getters and setters are implemented const-correct, that means they identify correctly if the state of the model changes when calling the method. This allows all the views to access the model with a const reference, which allows calling the methods marked const but none of the other methods. This enforces the rule that model data can only be changes by the controller and not the views directly.

The model implementation for the basic user interface and navigation is shown below. It is rather simple, it only stores the index of the currently selected envelope and provides getters and setters to read and write the selected envelope index respectively.

#ifndef __UI__MODEL__
#define __UI__MODEL__

namespace ui {
    class Model {
        public:
            Model(): _selectedEnvelope(0) {}

            void setSelectedEnvelopeIndex(unsigned int envelopeId) {
                _selectedEnvelope = envelopeId;
            }

            unsigned int getSelectedEnvelopeIndex() const {
                return _selectedEnvelope;
            }

        private:
            unsigned int _selectedEnvelope;
    };
}

#endif

The Controller
#

The controller takes a reference to the model during creation. Note that the reference is not const and allows the controller to change the model’s state. For the simple user interface the controller provides only the method selectEnvelope which allows to change the currently edited envelope.

The full implementation is below.

#ifndef __UI__CONTROLLER__
#define __UI__CONTROLLER__

#include "model.h"

namespace ui {
    class Controller {
        public:
            explicit Controller(Model & model): _model(model) {}

            void selectEnvelope(unsigned int envelopeId) {
                _model.setSelectedEnvelopeIndex(envelopeId);
            }

        private:
            Model & _model;
    };
}

#endif

The View
#

The view is more complicated than the model and the controller. It takes a const reference to the model and a reference to the controller as constructor parameters and is composed of several classes, one for each menu level and in turn, each of the classes for the menu levels contain a class for each of the pages in that menu level.

The relationship of the classes composing the view is depicted below.

classDiagram View *-- MenuLevel1View View *-- MenuLevel2View View *-- MenuLevel3View MenuLevel1View *-- EnvelopeSetupView MenuLevel1View *-- EnvelopeView MenuLevel1View *-- CurveView MenuLevel1View *-- RangeView MenuLevel1View *-- EffectsView MenuLevel1View *-- OutputView MenuLevel1View *-- TriggerView MenuLevel3View *-- MidiSetupView MenuLevel3View *-- SyncSetupView View : paint() View : processInput() MenuLevel1View : paint() MenuLevel1View : processView() MenuLevel2View : paint() MenuLevel2View : processView() MenuLevel3View : paint() MenuLevel3View : processView() EnvelopeSetupView : paint() EnvelopeSetupView : processView() EnvelopeView : paint() EnvelopeView : processView() CurveView : paint() CurveView : processView() RangeView : paint() RangeView : processView() EffectsView : paint() EffectsView : processView() OutputView : paint() OutputView : processView() TriggerView : paint() TriggerView : processView() MidiSetupView : paint() MidiSetupView : processView() SyncSetupView : paint() SyncSetupView : processView()

The view contains three classes, MenuLevel1View, MenuLevel2View and MenuLevel3View, one for each level in the menu structure. Each of these classes in turn contain one view for each page that menu level contains. The exception is MenuLevel2View, the class for the second menu level. Because the second level only displays one page, it doesn’t contain any classes but implements the logic for input processing and drawing directly.

Because all the views need to be able to read the current state from the model, they all store a const reference of the model. They also need the means to interact with the application, so they all store a reference of the controller.

The view and the related classes are primarily responsible for processing the user’s input and drawing the user interface. Both functions will be described in the following two sections.

Drawing the User Interface
#

To draw the user interface correctly the application needs two pieces of information, the current state of the view, and the current state of the model. The current state of the view and the current state of the model are changed while processing the user’s input, so technically the application processes the user input before drawing the application. But because drawing the user interface is slightly simpler, that functionality is described first.

Drawing the user interface is done by calling paint() on the view. The view in turn checks which menu level is active and the calls paint() for the class representing the menu level. The menu level is stored in the member variable _menuLevel. Note that in addition to calling the paint() method of the active menu level, the view also calls Update() of the hardware abstraction layer for the display. This is done here because Update() should be called exactly once after all the drawing is done.

void paint() {
    switch(_menuLevel) {
        case 0:
            _menuLevel1View.paint();
            break;
        
        case 1:
            _menuLevel2View.paint();
            break;
        
        case 2:
            _menuLevel3View.paint();
            break;

        default:
            break;
    }
    _hardware.display.Update();
}

The first menu level is implemented in MenuLevel1View. The first menu level contains seven pages and therefore MenuLevel1View contains seven different view classes, one for each page. Additionally, MenuLevel1View stores the currently active page for the level 1 menu. Drawing the active page is only a matter of finding the correct view for the active page.

void paint() {
    switch (_activeView)
    {
    case EnvelopeSetupViewType:
        _envelopeSetupView.paint();
        break;

    case EnvelopeViewType:
        _envelopeView.paint();
        break;

    case CurveViewType:
        _curveView.paint();
        break;

    case RangeViewType:
        _rangeView.paint();
        break;

    case EffectsViewType:
        _effectsView.paint();
        break;

    case OutputViewType:
        _outputView.paint();
        break;

    case TriggerViewType:
        _triggerView.paint();
        break;

    default:
        break;
    }

    _drawActiveEnvelopeIndicator();
}

Note that in addition to delegating the drawing of most of the screen to the respective view for the active page, MenuLevel1View also draws the active envelope index. This happens here, because all the pages share that particular information on the screen.

As final step it’s the views responsibility to paint the active page. The content of the active page is dependent on the specific functionality of the page. Because the application doesn’t implement any specific functionality yet, the only drawing the view does is drawing the name of the page in the top left corner of the screen.

To do that, the view first clears the screen by turning each pixel off bit calling _hardware.display.Fill(false). The view can and should do that as the first thing it does in its paint method because it is the first thing to draw something on the screen. It then moves the cursor to the top left corner _hardware.display.SetCursor(0, 0) and then writes the title of the page.

void paint() {
    _hardware.display.Fill(false);
    _hardware.display.SetCursor(0, 0);
    std::string title = "Effects";
    _hardware.display.WriteString(title.c_str(), Font_7x10, true);
}

Drawing for MenuLevel3View happens in pretty much the same way as in MenuLevel1View and will not be described here in detail. MenuLevel2View in contrast implements a very different functionality. It allows the user to select the currently edited envelope. It doesn’t use pages to implement that functionality but draws seven rectangles to allow the user to either select one of the six different envelopes or to navigate to menu level 3.

void paint() {
    _drawTitle();

    int x = margin;
    for (unsigned int i = 0; i < 4; i++) {
        if (i < 3) {
            _drawUpperSmallRect(x, std::to_string(i + 1), i == _selected);
            _drawLowerSmallRect(x, std::to_string(i + 4), i + 3 == _selected);
        } else {
            _drawFullRect(x, "S", 6 == _selected);
        }

        x += margin + box_width;
    }
}

First MenuLevel3View draws the title. As shown below, the title is drawn exactly as in the views described above.

void _drawTitle()
{
    _hardware.display.Fill(false);
    _hardware.display.SetCursor(0, 0);
    std::string title = "Select Envelope";
    _hardware.display.WriteString(title.c_str(), Font_7x10, true);
}

After drawing the title, it draws the rectangles. It draws six small rectangles and one bigger rectangle. The small rectangles each take about one eighth of the screen, while the big rectangle takes approximately a quarter. The currently selected entry is indicated by a filled rectangle with dark font, while the not selected choices are indicated with the outlines of the rectangle and white font in the middle.

This behavior is implemented in the three methods drawUpperSmallRect, drawLowerSmallRect and drawFullRect. drawUpperSmallRect draws small rectangles in the upper row, drawLowerSmallRect draws a small rectangle in the lower row. And finally, drawFullRect drawing the last, bigger rectangle for opening the setup screen.

The methods only differ in coordinates and this text will describe one of them, namely drawLowerSmallRect in detail.

void _drawLowerSmallRect(unsigned int x, std::string number, bool selected) {
    _hardware.display.DrawRect(x, 10 + small_box_height + margin, x + box_width, 63, true, selected);
    _hardware.display.SetCursor(x + text_offset, 20 + small_box_height + margin);
    _hardware.display.WriteString(number.c_str(), Font_7x10, !selected);
}

Each of the functions takes the same three parameters:

  • unsigned int x: Column of the box. Will be used to calculate the position on the x axis
  • std::string number: The number or character to draw in the middle of the box
  • bool selected: If the box is currently selected

Based on that, and some constants previously calculated using constexpr as follows.

constexpr unsigned int max_width = 128;
constexpr unsigned int max_height = 64 - 10;
constexpr unsigned int margin = 2;
constexpr unsigned int small_box_height = static_cast<unsigned int>((max_height - margin) / 2);
constexpr unsigned int usable_width_excluding_margin = max_width - margin * 8;
constexpr unsigned int box_width = static_cast<unsigned int>(usable_width_excluding_margin / 4);
constexpr unsigned int text_offset = static_cast<unsigned int>(box_width / 2) - 3; 

Together the functions will be called 6 times, once for each box on the screen and draw the boxes column-wise. This concludes the overview of the drawing process for views. The next section will give us an overview of user input handling was implemented.

Processing the User’s Input
#

Similar to drawing, user input processing makes use of the structure of the views for its implementation. The View class determines which of the second level implementation classes, from MenuLevel1View to MenuLevel3View, to call. Each of those call then the processing method of their active page.

Each level is then responsible to process the relevant inputs for itself. The view, as example, first tells the hardware abstraction layer of the daisy patch to update its own state by calling _hardware.ProcessAllControls(). It then handles the action it is responsible for, namely handling presses on the encoder, in the case of the view. And finally, only if it itself didn’t process any input, it calls processInput on each of the views.

void processInput() {
    _hardware.ProcessAllControls();

    if (_hardware.encoder.RisingEdge()) {
        _updateSelectedMenuLevel();
    } else {
        _processMenuLevel();
    }
}

In turn, the classes implementing the views for the menu levels, can now process their own inputs and then forward input processing to the active page. If the menu level has multiple pages, it will use the encoder to switch between them. Normally it would pass input processing on to the currently active page, but because the pages do not have any functionality yet, this doesn’t happen yet. Method processInput() of MenuLevel1View is presented below as an example.

void processInput() {
    if (_hardware.encoder.Increment() == -1) {
        auto newActiveView = _activeView - 1;
        if (newActiveView < 0) {
            _activeView = (Level1ViewTypes)(max_view_types - 1);
        } else {
            _activeView = (Level1ViewTypes)newActiveView;
        }
    } else if (_hardware.encoder.Increment() == 1) {
        _activeView = (Level1ViewTypes)((_activeView + 1) % max_view_types);
    }
}

It checks whether the encoder was recently incremented or decremented and the increments or decrements the active view index respectively. If the calculation results in an index out of bounds, either -1 or > max_view_types, it wraps around.

  • -1 becomes max_view_types - 1
  • > max_view_types becomes 0

Every possible input was now processed. And the section is concluded. Everything will be wrapped up in the next section when we introduce the UserInterace and the application loop, which tie the application together.

The User Interface
#

Pulling it all together is the user interface class. The user interface class creates the whole user interface on construction, reserving all the memory up front in order to avoid dynamic memory allocation.

classDiagram UserInterface *-- View UserInterface *-- Model UserInterface *-- Controller

The user interface class reserves the memory for Model, View and Controller during construction and provides two. Methods, processInput() and paint, which in turn call the respective methods of the view. That makes the whole class really simple and the source code is depicted below. The most important thing for the implementation is that the objects are created in the correct order, so that the correct dependencies can be passed on to the constructors.

#ifndef __UI__USER__INTERFACE__
#define __UI__USER__INTERFACE__

#include <memory>

#include <daisy_patch.h>

#include "controller.h"
#include "view.h"
#include "model.h"

namespace ui {
    class UserInterface {
        public:
            UserInterface(daisy::DaisyPatch & hardware):
                _model(),
                _controller(_model),
                _view(hardware, _model, _controller) {}

            void processInput() { _view.processInput(); }
            void paint() { _view.paint(); }

        private:
            Model _model;
            Controller _controller;
            View _view;
    };
}

#endif

The Application Loop
#

The final part for the implementation of the user interface is to implement the application loop in main(). main first initializes the application and then starts the application loop, an endless loop that first processes the input, then draws the application and finally sleeps for 1 ms.

int main(void)
{
	hardware.Init();
	hardware.SetAudioBlockSize(4);
	hardware.SetAudioSampleRate(SaiHandle::Config::SampleRate::SAI_48KHZ);
	hardware.StartAdc();
	hardware.StartAudio(AudioCallback);

    for(;;)
    {
		userInterface.processInput();
		userInterface.paint();
		hardware.DelayMs(1);
    }
}
Building Digi Env - This article is part of a series.
Part 3: This Article