Reproducing the UI of MOD-D3 with JUCE (3) : XYZ MapButtons

Introduction

In this article, we will implement the following XYZ MapButtons in addition to the UI we created in the previous article.

XYZ MapButtons do not have the ability to map any parameters, as we are only focusing on the UI implementation, but we will implement the ability to switch the XYZ RangeFader to a ThreeValue Slider.

One of the most interesting points to be explained in this article is how to make the default TextButton of JUCE look cool. So let’s do it.

Prerequisites

Creating the basic MapButton

Declaring TextButton object

First, we declare three objects of the TextButton class to create the XYZ MapButton.

class ModD3TutorialAudioProcessorEditor  : public juce::AudioProcessorEditor, public juce::Slider::Listener, public juce::KeyListener
{
・・・
private: 
    juce::TextButton xMapButton;
    juce::TextButton yMapButton;
    juce::TextButton zMapButton;

Setting the properties of MapButton

Next, call the following member function for each MapButton to set the details:

ModD3TutorialAudioProcessorEditor::ModD3TutorialAudioProcessorEditor (ModD3TutorialAudioProcessor& p)
    : AudioProcessorEditor (&p), audioProcessor (p)
{
・・・
    addAndMakeVisible (&xMapButton);
    xMapButton.setButtonText ("x.map");
    xMapButton.setClickingTogglesState (true);
    
    addAndMakeVisible (&yMapButton);
    yMapButton.setButtonText ("y.map");
    yMapButton.setClickingTogglesState (true);
    
    addAndMakeVisible (&zMapButton);
    zMapButton.setButtonText ("z.map");
    zMapButton.setClickingTogglesState (true);
}

setButtonText() is a member function that displays the specified text on the button, and setClickingTogglesState() is a member function that automatically switches the button to ON/OFF state upon mouse click.

Setting the placement and size

These should be placed on top of each RangeFaders as shown below:

void ModD3TutorialAudioProcessorEditor::resized()
{
・・・
    xMapButton.setBounds (15, xRangeFader.getY() - 25, getWidth() - 30, 25);
    yMapButton.setBounds (15, yRangeFader.getY() - 25, getWidth() - 30, 25);
    zMapButton.setBounds (15, zRangeFader.getY() - 25, getWidth() - 30, 25);    
}

This completes the minimum implementation. Let’s build and check it out.

As you can see when you actually mouse click on each MapButtons, These colors change a little depending on the ON/OFF status of the button.

Customizing look-and-feel

In this chapter, we will make the default look and feel closer to the original.

Before
After

Overriding drawButtonBackground()

drawButtonBackground() is a member function for setting the background and border of a button. Override this member function in the CustomLookAndFeel class created in the previous article.

First, add the following to the CustomLookAndFeel.h file:

class CustomLookAndFeel : public juce::LookAndFeel_V4
{
public:
・・・
    void drawButtonBackground (juce::Graphics& g,
                               juce::Button& button,
                               const juce::Colour& backgroundColour,
                               bool shouldDrawButtonAsHighlighted,
                               bool shouldDrawButtonAsDown) override;

Next, we implement the definition part of this member function in the CustomLookAndFeel.cpp file as follows:

void CustomLookAndFeel::drawButtonBackground (juce::Graphics& g, juce::Button& button, const juce::Colour& backgroundColour,
                                              bool shouldDrawButtonAsHighlighted, bool shouldDrawButtonAsDown)
{
    auto bounds = button.getLocalBounds().toFloat().reduced (0.5f, 0.5f);
    
    g.setColour (backgroundColour);
    juce::Path path;
    path.addRectangle (bounds.getX(), bounds.getY(), bounds.getWidth(), bounds.getHeight());
    g.fillPath (path);
    
    g.setColour (blackGrey);
    g.strokePath (path, juce::PathStrokeType (2.0f));
}

The above implementation makes it possible to depict a rectangular background and outer border line that matches the size of the button, and it does not have rounded corners.

Setting CustomLookAndFeel

Then, for each MapButton, call setLookAndFeel() to apply the CustomLookAndFeel.

ModD3TutorialAudioProcessorEditor::ModD3TutorialAudioProcessorEditor (ModD3TutorialAudioProcessor& p)
    : AudioProcessorEditor (&p), audioProcessor (p)
{
・・・
    addAndMakeVisible (&xMapButton);
    xMapButton.setLookAndFeel (&customLookAndFeel);
    xMapButton.setButtonText ("x.map");
    xMapButton.setClickingTogglesState (true);
    
    addAndMakeVisible (&yMapButton);
    yMapButton.setLookAndFeel (&customLookAndFeel);
    yMapButton.setButtonText ("y.map");
    yMapButton.setClickingTogglesState (true);
    
    addAndMakeVisible (&zMapButton);
    zMapButton.setLookAndFeel (&customLookAndFeel);
    zMapButton.setButtonText ("z.map");
    zMapButton.setClickingTogglesState (true);
}

So let’s build it and see what happens.

Switching to ThreeValue

The main feature of MapButton in MOD-D3 is to display the third parameter when the button is clicked. In this chapter, I will explain how to implement it.

Improving drawLinearSlider()

When I implemented RangeFader before, I overrode drawLinearSlider(), but it was completely TwoValue-only implementation. Therefore, I decided to add an implementation for ThreeValue to make it more generic.

When switching to ThreeValue, the third parameter should be depicted as an orange circle, as shown below:

void CustomLookAndFeel::drawLinearSlider (juce::Graphics& g, int x, int y, int width, int height,
                                          float sliderPos,
                                          float minSliderPos,
                                          float maxSliderPos,
                                          const juce::Slider::SliderStyle style, juce::Slider& slider)
{
    auto isTwoVal   = (style == juce::Slider::SliderStyle::TwoValueVertical   || style == juce::Slider::SliderStyle::TwoValueHorizontal);
    auto isThreeVal = (style == juce::Slider::SliderStyle::ThreeValueVertical || style == juce::Slider::SliderStyle::ThreeValueHorizontal);
    
    auto trackWidth = juce::jmin (2.0f, (float) height * 0.25f);

    juce::Point<float> startPoint (slider.isHorizontal() ? (float) x : (float) x + (float) width * 0.5f,
                                   slider.isHorizontal() ? (float) y + (float) height * 0.5f : (float) (height + y));

    juce::Point<float> endPoint (slider.isHorizontal() ? (float) (width + x) : startPoint.x,
                                 slider.isHorizontal() ? startPoint.y : (float) y);
 
    juce::Path backgroundTrack;
    backgroundTrack.startNewSubPath (startPoint);
    backgroundTrack.lineTo (endPoint);
    g.setColour (blackGrey);
    juce::PathStrokeType pathStrokeType (1.0);
    float dashedLength[2] = {2, 4};
    pathStrokeType.createDashedStroke(backgroundTrack, backgroundTrack, dashedLength, 2);
    g.strokePath(backgroundTrack, pathStrokeType);

    juce::Path valueTrack;
    juce::Point<float> minPoint, maxPoint, thumbPoint;

    if (isTwoVal || isThreeVal)
    {
        minPoint = { minSliderPos, (float) height * 0.5f };
        maxPoint = { maxSliderPos, (float) height * 0.5f };
        thumbPoint = { sliderPos, (float) height * 0.5f };
    }
    else
    {
        auto kx = slider.isHorizontal() ? sliderPos : ((float) x + (float) width * 0.5f);
        auto ky = slider.isHorizontal() ? ((float) y + (float) height * 0.5f) : sliderPos;

        minPoint = startPoint;
        maxPoint = { kx, ky };
    }

    auto minThumbWidth = getSliderThumbRadius (slider) - 22;
    auto maxThumbWidth = getSliderThumbRadius (slider) - 26;
    auto thumbWidth = getSliderThumbRadius (slider) - 19;
                    
    valueTrack.startNewSubPath (minPoint);
    valueTrack.lineTo (maxPoint);
    g.setColour ((isTwoVal || isThreeVal) ? grey : slider.findColour (juce::Slider::trackColourId));
    g.strokePath (valueTrack, { trackWidth, juce::PathStrokeType::curved, juce::PathStrokeType::rounded });
            
    if (! isTwoVal)
    {
        g.setColour (slider.findColour (juce::Slider::thumbColourId));
        g.fillEllipse (juce::Rectangle<float> (static_cast<float> (thumbWidth), static_cast<float> (thumbWidth)).withCentre (isThreeVal ? thumbPoint : maxPoint));
    }

    if (isTwoVal || isThreeVal)
    {
        int thumbBeingDragged = slider.getThumbBeingDragged();
                
        if (thumbBeingDragged == 1 || thumbBeingDragged == 2)
        {
            slider.setMouseCursor (juce::MouseCursor::NoCursor);
            
            g.setColour (offWhite);
            g.drawRoundedRectangle (juce::Rectangle<float> (static_cast<float> (thumbBeingDragged == 1 ? maxThumbWidth : minThumbWidth),
                                                            static_cast<float> (thumbBeingDragged == 1 ? maxThumbWidth : minThumbWidth))
                                                            .withCentre (thumbBeingDragged == 1 ? maxPoint : minPoint), 50, 1.0);
            
            g.setColour (grey);
            g.fillEllipse (juce::Rectangle<float> (static_cast<float> (thumbBeingDragged == 1 ? minThumbWidth : maxThumbWidth),
                                                   static_cast<float> (thumbBeingDragged == 1 ? minThumbWidth : maxThumbWidth))
                                                   .withCentre (thumbBeingDragged == 1 ? minPoint : maxPoint));
        }
        else
        {
            slider.setMouseCursor (juce::MouseCursor::NormalCursor);
          
            g.setColour (offWhite);
            g.drawRoundedRectangle (juce::Rectangle<float>(static_cast<float> (minThumbWidth), static_cast<float> (minThumbWidth)).withCentre (minPoint), 50, 1.0);
            g.drawRoundedRectangle (juce::Rectangle<float>(static_cast<float> (maxThumbWidth), static_cast<float> (maxThumbWidth)).withCentre (maxPoint), 50, 1.0);

        }
        
        if (isThreeVal)
        {
            g.setColour (stYellow);
            g.fillEllipse (juce::Rectangle<float> (static_cast<float> (thumbWidth), static_cast<float> (thumbWidth)).withCentre (thumbPoint));            
        }
    }

}

Inheriting Button Listener class

Inherit the listener class for Button and override buttonClicked() as shown below:

class ModD3TutorialAudioProcessorEditor  : public juce::AudioProcessorEditor, public juce::KeyListener, public juce::Button::Listener
{
public:
・・・
   void buttonClicked (juce::Button* button) override;

Then, add a listener to each MapButton so that it can catch events caused by mouse clicks.

ModD3TutorialAudioProcessorEditor::ModD3TutorialAudioProcessorEditor (ModD3TutorialAudioProcessor& p)
    : AudioProcessorEditor (&p), audioProcessor (p)
{
・・・
    addAndMakeVisible (&xMapButton);
    xMapButton.setLookAndFeel (&customLookAndFeel);
    xMapButton.setButtonText ("x.map");
    xMapButton.setClickingTogglesState (true);
    xMapButton.addListener (this);
    
    addAndMakeVisible (&yMapButton);
    yMapButton.setLookAndFeel (&customLookAndFeel);
    yMapButton.setButtonText ("y.map");
    yMapButton.setClickingTogglesState (true);
    yMapButton.addListener (this);
    
    addAndMakeVisible (&zMapButton);
    zMapButton.setLookAndFeel (&customLookAndFeel);
    zMapButton.setButtonText ("z.map");
    zMapButton.setClickingTogglesState (true);
    zMapButton.addListener (this);
}

The implementation of buttonClicked() is as follows:

void ModD3TutorialAudioProcessorEditor::buttonClicked (juce::Button* button)
{
    if (button == &xMapButton)
    {
        if (xMapButton.getToggleState())
        {
            xRangeFader.setSliderStyle (juce::Slider::ThreeValueHorizontal);
            xRangeFader.setValue (((xRangeFader.getMaxValue() + xRangeFader.getMinValue()) / 2));
        }
        else
        {
            xRangeFader.setSliderStyle (juce::Slider::TwoValueHorizontal);
        }
    }

    if (button == &yMapButton)
    {
        if (yMapButton.getToggleState())
        {
            yRangeFader.setSliderStyle (juce::Slider::ThreeValueHorizontal);
            yRangeFader.setValue (((yRangeFader.getMaxValue() + yRangeFader.getMinValue()) / 2));
        }
        else
        {
            yRangeFader.setSliderStyle (juce::Slider::TwoValueHorizontal);
        }
    }

    if (button == &zMapButton)
    {
        if (zMapButton.getToggleState())
        {
            zRangeFader.setSliderStyle (juce::Slider::ThreeValueHorizontal);
            zRangeFader.setValue (((zRangeFader.getMaxValue() + zRangeFader.getMinValue()) / 2));
        }
        else
        {
            zRangeFader.setSliderStyle (juce::Slider::TwoValueHorizontal);
        }
    }
}

First, getToggleState() is used to check whether the button is in the ON state or OFF state. Then, if it is ON, the RangeFader will switch to ThreeValue, and the initial value of the third parameter will be set to the average value.

Overriding sliderValueChanged()

At the moment, when I move the Thumb(min or max) of RangeFader, which is in ThreeValue mode, the third parameter does not move with it. Not only at the moment of switching to ThreeValue, but also when moving Thumb, I want this parameter to remain at the average of the minimum and maximum values.

That’s why we inherit from the Slider’s Listener class and override sliderValueChanged().

class ModD3TutorialAudioProcessorEditor  : public juce::AudioProcessorEditor, public juce::Slider::Listener, public juce::KeyListener, public juce::Button::Listener
{
public:
・・・
    void sliderValueChanged (juce::Slider *slider) override;

Add a listener to the NumberBox as well as the RangeFader:

ModD3TutorialAudioProcessorEditor::ModD3TutorialAudioProcessorEditor (ModD3TutorialAudioProcessor& p)
    : AudioProcessorEditor (&p), audioProcessor (p)
{
・・・
    xRangeFader.addListener (this);
    yRangeFader.addListener (this);
    zRangeFader.addListener (this);
    
    xMinNumberBox.addListener (this);   
    xMaxNumberBox.addListener (this);
    yMinNumberBox.addListener (this);
    yMaxNumberBox.addListener (this);
    zMinNumberBox.addListener (this);         
    zMaxNumberBox.addListener (this);

Then, implement SliderValueChanged() as shown below:

void ModD3TutorialAudioProcessorEditor::sliderValueChanged (juce::Slider *slider)
{
    if (slider == &xRangeFader || slider == &xMinNumberBox || slider == &xMaxNumberBox)
    {
        if (xRangeFader.isThreeValue())
            xRangeFader.setValue ((xRangeFader.getMaxValue() + xRangeFader.getMinValue()) / 2);
    }
    
    if (slider == &yRangeFader || slider == &yMinNumberBox || slider == &yMaxNumberBox)
    {
        if (yRangeFader.isThreeValue())
            yRangeFader.setValue ((yRangeFader.getMaxValue() + yRangeFader.getMinValue()) / 2);
    }
    
    if (slider == &zRangeFader || slider == &zMinNumberBox || slider == &zMaxNumberBox)
    {
        if (zRangeFader.isThreeValue())
            zRangeFader.setValue ((zRangeFader.getMaxValue() + zRangeFader.getMinValue()) / 2);
    }
}

Now let’s build and drag!

Even if we move Thumb in this way, the value of the third parameter will now remain fixed at the average of the maximum and minimum values.

Conclusion

In this article, I explained how to implement XYZ MapButtons. As far as appearance is concerned, it is quite similar to the original MOD-D3.

If you have a more efficient way of writing code, please let us know in the comments or DM on Twitter. Thank you for reading all the way to the end.

Comments

Copied title and URL