Chapter 7: The Canvas of Modular Control – Dynamic JUCE Component Architecture, Scrollable Viewports, Reordering, and Collapsible UI Panels
Executive Overview
In Chapters 1 through 5 of the SPANDAN (स्पंदन) Cookbook, we designed and implemented the core digital signal processing (DSP) engine: Numerically Controlled Oscillators (NCOs), Topology-Preserving Transform (TPT) State Variable Filters, exponential ADSR envelopes, FM synthesis networks, and lock-free Radix-2 FFT spectral visualizers.
However, as a synthesizer expands to include multiple oscillators, filter banks, envelope generators, LFOs, modulation matrices, and audio effects (such as saturation, delay, and reverb), a fixed, static graphical user interface (GUI) becomes unusable. Static UI layouts force controls into cramped windows or hidden menus, destroying workflow usability and limiting modular flexibility.
To deliver an intuitive, user-friendly interface that rivals commercial synthesizers while maintaining high performance on baseline hardware, SPANDAN implements a Dynamic JUCE Component Architecture.
This chapter explores the C++ architecture and JUCE framework mechanisms required to build a dynamic, scrollable, and modular synth UI:
- Scrollable Viewports (
juce::Viewport): Hosting a dynamic canvas component that expands vertically beyond window boundaries without pixel distortion or fixed aspect ratio restrictions. - Dynamic Component Allocation: Constructing, registering, and destroying UI panels dynamically at runtime using smart pointers (
std::unique_ptr) and RAII principles. - Thread-Safe Parameter Binding: Connecting dynamic UI controls (rotary sliders, combo boxes) to JUCE’s
AudioProcessorValueTreeState(APVTS) via thread-safe attachments. - Dynamic Component Reordering: Implementing a modular rack system where synth modules can be visually reordered, swapped, or moved up/down in the signal/UI pipeline.
- Collapsible UI Panels (Minimize & Expand): Toggling UI modules between expanded and minimized states, dynamically recalculating child component heights and triggering Viewport canvas layout updates.
📋 Table of Contents
- JUCE GUI Architecture: PluginEditor, Viewport, and Component Hierarchy
- Scrollable Viewports (
juce::Viewport) & Canvas Management - Dynamic Component Creation & Lifetime Management with Smart Pointers
- APVTS Thread-Safe Parameter Attachments in Dynamic Components
- Dynamic Component Re-Ordering Mechanics
- Collapsible Panels: Minimizing & Expanding Component Heights
- Full C++ Implementation:
ModulePanel.h,ModuleContainer.h, andPluginEditor.cpp - Cyber-Jungle Theme Integration (
LookAndFeel_V4) - Academic Viva Defense & University Panel Q&A
1. JUCE GUI Architecture: PluginEditor, Viewport, and Component Hierarchy
In the JUCE framework, the audio processing logic and the graphical user interface are strictly separated into two distinct C++ classes:
PluginProcessor(The Brain): Executes real-time DSP math, audio callbacks (processBlock), and parameter management within a high-priority, thread-safe audio thread.PluginEditor(The Face): Inherits fromjuce::AudioProcessorEditorand manages UI rendering, component layouts, user input events, and visualizers on the OS GUI thread.
+-----------------------------------------------------------------------------------+
| SpandanAudioProcessorEditor |
| +-----------------------------------------------------------------------------+ |
| | Plugin Header (Title, Subtitle, Credits, Master Output Level) | |
| +-----------------------------------------------------------------------------+ |
| | juce::Viewport (Scrollable Window Container) | |
| | +-----------------------------------------------------------------------+ | |
| | | ModuleContainerComponent (Canvas Component owned by Viewport) | | |
| | | +-----------------------------------------------------------------+ | | |
| | | | ModulePanel 1: OSC 1 (Expanded: 180px) | | | |
| | | +-----------------------------------------------------------------+ | | |
| | | | ModulePanel 2: OSC 2 (Minimized: 35px) | | | |
| | | +-----------------------------------------------------------------+ | | |
| | | | ModulePanel 3: SVF Filter (Expanded: 180px) | | | |
| | | +-----------------------------------------------------------------+ | | |
| | | | ModulePanel 4: ADSR Envelope (Expanded: 160px) | | | |
| | | +-----------------------------------------------------------------+ | | |
| | | | ... Additional Dynamic Modules (LFO, FX, Visualizers) | | | |
| | | +-----------------------------------------------------------------+ | | |
| | +-----------------------------------------------------------------------+ | |
| +-----------------------------------------------------------------------------+ |
| | On-Screen MIDI Keyboard Component | |
+-----------------------------------------------------------------------------------+
Component Hierarchy & Parent-Child Relationships
Every visual element in JUCE inherits from juce::Component. Components form a tree hierarchy where parent components own, position, and clip child components:
- Top-Level Window (
SpandanAudioProcessorEditor): Contains persistent top elements (header title, master output volume, global preset selectors) and bottom elements (MIDI keyboard). - Scroll Container (
juce::Viewport): Placed in the center of the main editor window. - Canvas Component (
ModuleContainerComponent): Placed inside the Viewport viasetViewedComponent(&container, false). Its height expands dynamically as modules are added, expanded, or reordered. - Child Panels (
ModulePanel): Represent individual synth building blocks (Oscillators, Filters, Envelopes, Effects, Oscilloscope).
2. Scrollable Viewports (juce::Viewport) & Canvas Management
When building complex VST plugins, fixed window dimensions lead to squeezed controls or multi-tab menus that hide controls from sound designers. JUCE provides juce::Viewport as a scrollable viewport container that handles clipping, scrollbar rendering, and mouse wheel navigation.
JUCE Viewport Window (Visible Clipping Region)
+--------------------------------------------+ ▲ Scroll Up
| Module 1: Oscillator 1 | |
| Module 2: Oscillator 2 (Minimized) | |
| Module 3: State Variable Filter | |
+--------------------------------------------+ ▼ Scroll Down
: Module 4: ADSR Envelope (Clipped) :
: Module 5: Real-Time FFT Analyzer (Clipped) :
+............................................+
Total Canvas (ModuleContainerComponent Bounds)
Key Viewport Methods & Mechanics
To configure a juce::Viewport inside JUCE:
viewport.setViewedComponent(&moduleContainer, false); // false = viewport does NOT take ownership deletion
viewport.setScrollBarsShown(true, false); // Vertical scrollbar ON, Horizontal OFF
viewport.setSingleStepSizes(0, 15); // Mouse-wheel scroll step speed
setViewedComponent(Component* content, bool deleteWhenNoLongerNeeded): Assigns the internal canvas component (moduleContainer) to the Viewport.- Canvas Bounds Management: The
Viewportautomatically clips child components outside its visible bounds. The internal canvas (moduleContainer) must explicitly compute its total height based on child modules and callsetSize(viewportWidth, calculatedTotalHeight). - Scroll Position Calculation: When
isCollapsedor module ordering changes,moduleContainerrecalculates its height and positions. The Viewport automatically updates scrollbar ranges without resetting the user’s scroll offset.
3. Dynamic Component Creation & Lifetime Management with Smart Pointers
Static C++ class members (e.g. OscillatorPanel osc1Panel;) require hardcoded UI layouts that cannot be instantiated on-the-fly or reordered dynamically.
SPANDAN utilizes modern C++ smart pointers (std::unique_ptr) and RAII (Resource Acquisition Is Initialization) to manage dynamic UI components safely:
std::vector<std::unique_ptr<ModulePanel>> modules;
Memory Management Architecture
ModuleContainerComponent (Owner)
│
├──> std::vector<std::unique_ptr<ModulePanel>>
│ │
│ ├──> ModulePanel 1 [OSC 1] (Heap Allocated)
│ ├──> ModulePanel 2 [Filter] (Heap Allocated)
│ └──> ModulePanel 3 [ADSR] (Heap Allocated)
│
└──> addAndMakeVisible(*module)
│
└──> Registered with JUCE Component Tree
Dynamic Insertion & Deletion Flow
When a user adds a new module or resets the synth rack:
- Instantiation:
auto newModule = std::make_unique<ModulePanel>(moduleType, apvts);allocates the panel on the heap. - Registration:
addAndMakeVisible(*newModule);informs JUCE that the child component should be rendered and receive input events. - Storage:
modules.push_back(std::move(newModule));transfers exclusive ownership to the container vector. - Destruction & Safety: When a module is removed (
modules.erase(...)),std::unique_ptrautomatically frees heap memory, while JUCE’s parent component automatically unregisters child pointers, eliminating dangling references.
4. APVTS Thread-Safe Parameter Attachments in Dynamic Components
Connecting UI controls (rotary sliders, combo boxes) to background DSP parameters requires absolute thread safety. JUCE provides juce::AudioProcessorValueTreeState (APVTS) to manage parameter states between the audio thread and the GUI thread.
GUI Thread (PluginEditor) Audio Thread (PluginProcessor)
+--------------------------+ +-----------------------------+
| juce::Slider Control | | Real-Time DSP Loop |
+-------------┬------------+ +--------------▲--------------+
│ │
▼ │
+--------------------------+ +--------------┴--------------+
| APVTS::SliderAttachment | ──(Lock-Free)──►| APVTS Parameter Memory |
| (RAII Managed Pointer) | | (Atomic Float / Lock-Free) |
+--------------------------+ +-----------------------------+
Managing Attachments in Dynamic Panels
When UI components are created dynamically at runtime, their parameter attachments must be created dynamically and tied strictly to the lifetime of the component:
class ModulePanel : public juce::Component
{
public:
ModulePanel(const juce::String& panelName,
juce::AudioProcessorValueTreeState& apvts,
const juce::String& paramID,
juce::Slider::SliderStyle style)
{
slider.setSliderStyle(style);
slider.setTextBoxStyle(juce::Slider::TextBoxBelow, false, 60, 20);
addAndMakeVisible(slider);
// Bind attachment dynamically
attachment = std::make_unique<juce::AudioProcessorValueTreeState::SliderAttachment>(
apvts, paramID, slider);
}
private:
juce::Slider slider;
std::unique_ptr<juce::AudioProcessorValueTreeState::SliderAttachment> attachment;
};
When ModulePanel is destroyed, attachment is destroyed first, safely detaching the UI control from APVTS before the slider itself is deallocated, preventing race conditions or null pointer dereferences during runtime UI updates.
5. Dynamic Component Re-Ordering Mechanics
A key feature of modern software synthesizers is modular reordering—allowing users to swap module positions (e.g. moving Oscillator 2 above Oscillator 1, or re-arranging effect chains).
Component Re-Ordering Operation
Initial Order Vector: Move Module 2 Up:
+-----------------------+ +-----------------------+
| Index 0: OSC 1 | | Index 0: Filter | ▲ Moved Up
| Index 1: Filter | ───────► | Index 1: OSC 1 | ▼ Shifted Down
| Index 2: ADSR | | Index 2: ADSR |
+-----------------------+ +-----------------------+
Algorithmic Re-Ordering Steps
- Vector Swap: Swap smart pointers inside
std::vector<std::unique_ptr<ModulePanel>>:std::swap(modules[indexA], modules[indexB]); - Z-Order Adjustment: Call
toFront(false)on child components in their new sequence to ensure proper focus and rendering order:for (auto& module : modules) module->toFront(false); - Layout Recalculation: Trigger
resized()onModuleContainerComponentto update bounding boxes and vertical $y$-positions.
6. Collapsible Panels: Minimizing & Expanding Component Heights
To optimize visual space in a scrollable Viewport, each ModulePanel includes a Header Bar with a minimize/expand toggle button.
Expanded Module State (Height = 180px)
+-----------------------------------------------------------+ [ − ] Minimize
| [≡] OSCILLATOR 1 PANEL | (Header: 35px)
+-----------------------------------------------------------+
| Waveform: [ Sawtooth ▼ ] Detune: ( ◯ ) Octave: [ 0 ▼ ]| (Body: 145px)
| Cutoff: ( ◯ ) Mix: ( ◯ ) Phase: ( ◯ ) |
+-----------------------------------------------------------+
Minimized / Collapsed Module State (Height = 35px)
+-----------------------------------------------------------+ [ + ] Expand
| [≡] OSCILLATOR 1 PANEL | (Header: 35px)
+-----------------------------------------------------------+ (Body Hidden)
Height Calculation Math
Each ModulePanel tracks its collapsed state using a boolean flag isCollapsed:
For standard SPANDAN modules:
- $H_{\text{header}} = 35 \text{ px}$
- $H_{\text{body}} = 145 \text{ px}$
- $H_{\text{expanded}} = 180 \text{ px}$
Canvas Recalculation Loop
When a user clicks the collapse button on module $i$:
modules[i]->setCollapsed(!modules[i]->isCollapsed());- Module $i$ hides/shows its body content (
bodyContainer.setVisible(!isCollapsed)). - The parent
ModuleContainerComponentexecutes its layout loop:
- The container updates its bounds:
setSize(containerWidth, H_total). juce::Viewportautomatically adjusts its scrollbars to reflect $H_{\text{total}}$ [146].
7. Full C++ Implementation: ModulePanel.h, ModuleContainer.h, and PluginEditor.cpp
Here is the complete, production-ready C++ implementation for SPANDAN’s dynamic UI layout architecture.
Header: ModulePanel.h
#pragma once
#include <JuceHeader.h>
class ModulePanel : public juce::Component
{
public:
using LayoutChangedCallback = std::function<void()>;
using OrderChangedCallback = std::function<void(ModulePanel*, bool)>; // true = move up, false = move down
ModulePanel(const juce::String& titleText, juce::Colour headerColour)
: title(titleText), accentColour(headerColour)
{
// Header Setup
headerBar.onClick = [this] { toggleCollapse(); };
addAndMakeVisible(headerBar);
titleLabel.setText(titleText, juce::dontSendNotification);
titleLabel.setFont(juce::FontOptions(16.0f, juce::Font::bold));
titleLabel.setColour(juce::Label::textColourId, juce::Colours::white);
addAndMakeVisible(titleLabel);
toggleButton.setButtonText("-");
toggleButton.onClick = [this] { toggleCollapse(); };
addAndMakeVisible(toggleButton);
moveUpButton.setButtonText("▲");
moveUpButton.onClick = [this] { if (onOrderChanged) onOrderChanged(this, true); };
addAndMakeVisible(moveUpButton);
moveDownButton.setButtonText("▼");
moveDownButton.onClick = [this] { if (onOrderChanged) onOrderChanged(this, false); };
addAndMakeVisible(moveDownButton);
// Body Container Setup
addAndMakeVisible(bodyContainer);
}
~ModulePanel() override = default;
void setOnLayoutChanged(LayoutChangedCallback callback) { onLayoutChanged = callback; }
void setOnOrderChanged(OrderChangedCallback callback) { onOrderChanged = callback; }
juce::Component& getBody() { return bodyContainer; }
bool getIsCollapsed() const { return isCollapsed; }
int getPreferredHeight() const
{
return isCollapsed ? headerHeight : (headerHeight + bodyHeight);
}
void setBodyHeight(int newBodyHeight)
{
bodyHeight = newBodyHeight;
resized();
}
void toggleCollapse()
{
isCollapsed = !isCollapsed;
toggleButton.setButtonText(isCollapsed ? "+" : "-");
bodyContainer.setVisible(!isCollapsed);
if (onLayoutChanged)
onLayoutChanged();
}
void paint(juce::Graphics& g) override
{
// Draw Header Background
g.setColour(accentColour);
g.fillRoundedRectangle(getLocalBounds().removeFromTop(headerHeight).toFloat(), 4.0f);
// Outer Border
g.setColour(juce::Colours::darkgrey);
g.drawRoundedRectangle(getLocalBounds().toFloat(), 4.0f, 1.0f);
}
void resized() override
{
auto bounds = getLocalBounds();
auto header = bounds.removeFromTop(headerHeight);
// Header controls layout
moveDownButton.setBounds(header.removeFromRight(25).reduced(2));
moveUpButton.setBounds(header.removeFromRight(25).reduced(2));
toggleButton.setBounds(header.removeFromRight(30).reduced(2));
titleLabel.setBounds(header.reduced(6, 2));
// Body layout
if (!isCollapsed)
{
bodyContainer.setBounds(bounds.reduced(4));
}
}
private:
juce::String title;
juce::Colour accentColour;
bool isCollapsed { false };
int headerHeight { 35 };
int bodyHeight { 145 };
juce::Button headerBar;
juce::Label titleLabel;
juce::TextButton toggleButton;
juce::TextButton moveUpButton;
juce::TextButton moveDownButton;
juce::Component bodyContainer;
LayoutChangedCallback onLayoutChanged;
OrderChangedCallback onOrderChanged;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(ModulePanel)
};
Header: ModuleContainerComponent.h
#pragma once
#include <JuceHeader.h>
#include "ModulePanel.h"
class ModuleContainerComponent : public juce::Component
{
public:
using LayoutUpdatedCallback = std::function<void()>;
ModuleContainerComponent() = default;
~ModuleContainerComponent() override = default;
void setOnLayoutUpdated(LayoutUpdatedCallback callback)
{
onLayoutUpdated = callback;
}
void addModule(std::unique_ptr<ModulePanel> module)
{
auto* ptr = module.get();
ptr->setOnLayoutChanged([this] { updateLayout(); });
ptr->setOnOrderChanged([this](ModulePanel* m, bool up) { moveModule(m, up); });
addAndMakeVisible(*ptr);
modules.push_back(std::move(module));
updateLayout();
}
void moveModule(ModulePanel* module, bool moveUp)
{
auto it = std::find_if(modules.begin(), modules.end(),
[module](const std::unique_ptr<ModulePanel>& p) { return p.get() == module; });
if (it == modules.end()) return;
size_t index = std::distance(modules.begin(), it);
if (moveUp && index > 0)
{
std::swap(modules[index], modules[index - 1]);
}
else if (!moveUp && index + 1 < modules.size())
{
std::swap(modules[index], modules[index + 1]);
}
updateLayout();
}
void updateLayout()
{
int y = padding;
int width = getWidth() > 0 ? getWidth() - (padding * 2) : 740;
for (auto& module : modules)
{
int moduleHeight = module->getPreferredHeight();
module->setBounds(padding, y, width, moduleHeight);
y += moduleHeight + padding;
}
setSize(getWidth(), y);
if (onLayoutUpdated)
onLayoutUpdated();
}
void resized() override
{
updateLayout();
}
private:
std::vector<std::unique_ptr<ModulePanel>> modules;
int padding { 8 };
LayoutUpdatedCallback onLayoutUpdated;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(ModuleContainerComponent)
};
Source: PluginEditor.cpp (Integration with juce::Viewport)
#include "PluginProcessor.h"
#include "PluginEditor.h"
SpandanAudioProcessorEditor::SpandanAudioProcessorEditor(SpandanAudioProcessor& p)
: AudioProcessorEditor(&p), audioProcessor(p)
{
// Configure Viewport
viewport.setViewedComponent(&moduleContainer, false);
viewport.setScrollBarsShown(true, false);
viewport.setSingleStepSizes(0, 15);
addAndMakeVisible(viewport);
// Whenever container resizes, trigger viewport refresh
moduleContainer.setOnLayoutUpdated([this] {
viewport.setViewedComponentRectangle(moduleContainer.getBounds());
});
// Populate Dynamic Modules
auto osc1Panel = std::make_unique<ModulePanel>("OSCILLATOR 1", juce::Colour(0xFF1E5631));
// Add OSC1 Sliders & Attachments into osc1Panel->getBody() here...
moduleContainer.addModule(std::move(osc1Panel));
auto osc2Panel = std::make_unique<ModulePanel>("OSCILLATOR 2", juce::Colour(0xFF1E5631));
moduleContainer.addModule(std::move(osc2Panel));
auto filterPanel = std::make_unique<ModulePanel>("STATE VARIABLE FILTER", juce::Colour(0xFF2E8B57));
moduleContainer.addModule(std::move(filterPanel));
auto adsrPanel = std::make_unique<ModulePanel>("ADSR ENVELOPE", juce::Colour(0xFFB8860B));
moduleContainer.addModule(std::move(adsrPanel));
setSize(800, 600);
}
void SpandanAudioProcessorEditor::resized()
{
auto bounds = getLocalBounds();
// Top Title Bar
auto headerBounds = bounds.removeFromTop(50);
// Bottom MIDI Keyboard
auto keyboardBounds = bounds.removeFromBottom(100);
// Scrollable Center Viewport
viewport.setBounds(bounds);
moduleContainer.setSize(viewport.getMaximumVisibleWidth(), moduleContainer.getHeight());
}
8. Cyber-Jungle Theme Integration (LookAndFeel_V4)
To maintain visual cohesion across dynamic UI components, SPANDAN applies the custom Cyber-Jungle design theme specification:
| Element | Color Name | Hex Code | JUCE LookAndFeel Mapping |
|---|---|---|---|
| Canvas Background | Off-white | 0xFFF0FDF4 |
ResizableWindow::backgroundColourId |
| Primary Headers | Jungle Green | 0xFF2E8B57 |
HeaderBar / Accent Colors |
| Highlights & Borders | Dark Gold | 0xFFB8860B |
Slider::rotarySliderFillColourId |
| Text & Outlines | Earthy Brown | 0xFF8B4513 |
Label::textColourId |
| Secondary Accents | Deep Forest Green | 0xFF1E5631 |
GroupComponent::outlineColourId |
class CyberJungleLookAndFeel : public juce::LookAndFeel_V4
{
public:
CyberJungleLookAndFeel()
{
setColour(juce::ResizableWindow::backgroundColourId, juce::Colour(0xFFF0FDF4));
setColour(juce::Slider::thumbColourId, juce::Colour(0xFFB8860B));
setColour(juce::Slider::rotarySliderFillColourId, juce::Colour(0xFF2E8B57));
setColour(juce::Label::textColourId, juce::Colour(0xFF8B4513));
}
void drawRotarySlider(juce::Graphics& g, int x, int y, int width, int height,
float sliderPos, float rotaryStartAngle, float rotaryEndAngle,
juce::Slider& slider) override
{
auto bounds = juce::Rectangle<int>(x, y, width, height).toFloat().reduced(4.0f);
auto radius = juce::jmin(bounds.getWidth(), bounds.getHeight()) / 2.0f;
auto center = bounds.getCentre();
auto angle = rotaryStartAngle + sliderPos * (rotaryEndAngle - rotaryStartAngle);
// Outer Cyber Ring
g.setColour(juce::Colour(0xFF2E8B57).withAlpha(0.3f));
g.drawEllipse(bounds, 3.0f);
// Metallic Mesh Cap
g.setColour(juce::Colour(0xFF1E5631));
g.fillEllipse(center.x - radius * 0.7f, center.y - radius * 0.7f, radius * 1.4f, radius * 1.4f);
// Glowing Gold Pointer Indicator
juce::Point<float> thumbPoint(
center.x + radius * 0.75f * std::sin(angle),
center.y - radius * 0.75f * std::cos(angle));
g.setColour(juce::Colour(0xFFB8860B));
g.fillEllipse(thumbPoint.x - 4.0f, thumbPoint.y - 4.0f, 8.0f, 8.0f);
}
};