From a9abca9e15ffcdf818ccc8dfe62cfa2927acaad4 Mon Sep 17 00:00:00 2001 From: Igor Barcik Date: Tue, 8 Sep 2026 14:55:22 +0200 Subject: [PATCH] feat(gui): add GUI components and displays --- Source/GUI/Display.cpp | 27 ++++++ Source/GUI/Display.h | 24 +++++ Source/GUI/EnvelopeDisplay.cpp | 80 +++++++++++++++++ Source/GUI/EnvelopeDisplay.h | 24 +++++ Source/GUI/FilterDisplay.cpp | 79 +++++++++++++++++ Source/GUI/FilterDisplay.h | 30 +++++++ Source/GUI/Knob.cpp | 24 +++++ Source/GUI/Knob.h | 27 ++++++ Source/GUI/LFODisplay.cpp | 128 +++++++++++++++++++++++++++ Source/GUI/LFODisplay.h | 34 +++++++ Source/GUI/Panel.cpp | 26 ++++++ Source/GUI/Panel.h | 23 +++++ Source/GUI/SerumLookAndFeel.h | 156 +++++++++++++++++++++++++++++++++ Source/GUI/Slider.cpp | 13 +++ Source/GUI/Slider.h | 18 ++++ Source/GUI/ToggleButton.cpp | 71 +++++++++++++++ Source/GUI/ToggleButton.h | 39 +++++++++ Source/GUI/WaveformDisplay.cpp | 45 ++++++++++ Source/GUI/WaveformDisplay.h | 30 +++++++ 19 files changed, 898 insertions(+) create mode 100644 Source/GUI/Display.cpp create mode 100644 Source/GUI/Display.h create mode 100644 Source/GUI/EnvelopeDisplay.cpp create mode 100644 Source/GUI/EnvelopeDisplay.h create mode 100644 Source/GUI/FilterDisplay.cpp create mode 100644 Source/GUI/FilterDisplay.h create mode 100644 Source/GUI/Knob.cpp create mode 100644 Source/GUI/Knob.h create mode 100644 Source/GUI/LFODisplay.cpp create mode 100644 Source/GUI/LFODisplay.h create mode 100644 Source/GUI/Panel.cpp create mode 100644 Source/GUI/Panel.h create mode 100644 Source/GUI/SerumLookAndFeel.h create mode 100644 Source/GUI/Slider.cpp create mode 100644 Source/GUI/Slider.h create mode 100644 Source/GUI/ToggleButton.cpp create mode 100644 Source/GUI/ToggleButton.h create mode 100644 Source/GUI/WaveformDisplay.cpp create mode 100644 Source/GUI/WaveformDisplay.h diff --git a/Source/GUI/Display.cpp b/Source/GUI/Display.cpp new file mode 100644 index 0000000..0f4b2f4 --- /dev/null +++ b/Source/GUI/Display.cpp @@ -0,0 +1,27 @@ +#include "Display.h" + +namespace serum +{ + +void Display::paint (juce::Graphics& g) +{ + const auto b = getLocalBounds().toFloat(); + g.setColour (juce::Colours::black.withAlpha (0.45f)); + g.fillRoundedRectangle (b, 4.0f); + g.setColour (theme::outline); + g.drawRoundedRectangle (b.reduced (0.5f), 4.0f, 1.0f); + + if (title.isNotEmpty()) + { + g.setColour (theme::textDim); + g.setFont (juce::Font (10.0f)); + g.drawText (title, 0, 3, getWidth(), 14, juce::Justification::centred, false); + } + + g.setColour (theme::accent); + g.setFont (juce::Font (14.0f, juce::Font::bold)); + g.drawText (value, 0, title.isNotEmpty() ? 16 : 0, getWidth(), getHeight() - (title.isNotEmpty() ? 16 : 0), + juce::Justification::centred, false); +} + +} // namespace serum diff --git a/Source/GUI/Display.h b/Source/GUI/Display.h new file mode 100644 index 0000000..a850432 --- /dev/null +++ b/Source/GUI/Display.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include "../Resources.h" + +namespace serum +{ + +// =========================================================================== +// LED-style text readout (e.g. preset name / master level). +// =========================================================================== +class Display : public juce::Component +{ +public: + void setTitle (const juce::String& t) { title = t; repaint(); } + void setValue (const juce::String& v) { value = v; repaint(); } + + void paint (juce::Graphics& g) override; + +private: + juce::String title, value; +}; + +} // namespace serum diff --git a/Source/GUI/EnvelopeDisplay.cpp b/Source/GUI/EnvelopeDisplay.cpp new file mode 100644 index 0000000..7755be2 --- /dev/null +++ b/Source/GUI/EnvelopeDisplay.cpp @@ -0,0 +1,80 @@ +#include "EnvelopeDisplay.h" + +namespace serum +{ + +void EnvelopeDisplay::setParams (float a, float d, float s, float r, float c) +{ + attack = a; decay = d; sustain = s; release = r; curve = c; +} + +void EnvelopeDisplay::paint (juce::Graphics& g) +{ + const auto b = getLocalBounds().toFloat().reduced (4.0f); + g.setColour (juce::Colours::black.withAlpha (0.4f)); + g.fillRoundedRectangle (b.expanded (4.0f), 4.0f); + g.setColour (theme::outline); + g.drawRoundedRectangle (b.expanded (4.0f).reduced (0.5f), 4.0f, 1.0f); + + const float atkShape = 0.3f + curve * 2.7f; + const float decShape = 3.0f - curve * 2.7f; + + // Normalise durations for display (attack 0..1, decay 0..0.6, release 0..0.6). + const float aSec = maps::toSeconds (attack); + const float dSec = maps::toSeconds (decay); + const float rSec = maps::toSeconds (release); + const float total = aSec + dSec + rSec + 0.01f; + const float ax = (aSec / total); + const float dx = (dSec / total); + const float rx = (rSec / total); + + const float left = b.getX(); + const float right = b.getRight(); + const float bottom = b.getBottom(); + const float top = b.getY(); + const float sustainY = bottom - sustain * b.getHeight(); + + juce::Path path; + path.startNewSubPath (left, bottom); + path.lineTo (left, top); + + // Attack (curve-shaped). + const int steps = 48; + const float peakX = left + ax * b.getWidth(); + for (int i = 0; i <= steps; ++i) + { + const float p = (float) i / steps; + const float y = top + (bottom - top) * std::pow (p, atkShape); + path.lineTo (left + p * (peakX - left), y); + } + + // Decay to sustain. + const float decX = peakX + dx * b.getWidth(); + for (int i = 0; i <= steps; ++i) + { + const float p = (float) i / steps; + const float y = sustainY + (bottom - sustainY) * std::pow (1.0f - p, decShape); + path.lineTo (peakX + p * (decX - peakX), y); + } + path.lineTo (decX, sustainY); + path.lineTo (right - rx * b.getWidth(), sustainY); + + // Release. + const float relStartX = right - rx * b.getWidth(); + for (int i = 0; i <= steps; ++i) + { + const float p = (float) i / steps; + const float y = sustainY + (bottom - sustainY) * std::pow (1.0f - p, decShape); + path.lineTo (relStartX + p * (right - relStartX), y); + } + + g.setColour (theme::accent2.withAlpha (0.3f)); + juce::Path fill = path; + fill.lineTo (right, bottom); + fill.closeSubPath(); + g.fillPath (fill); + g.setColour (theme::accent2); + g.strokePath (path, juce::PathStrokeType (1.5f)); +} + +} // namespace serum diff --git a/Source/GUI/EnvelopeDisplay.h b/Source/GUI/EnvelopeDisplay.h new file mode 100644 index 0000000..0d4b691 --- /dev/null +++ b/Source/GUI/EnvelopeDisplay.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include "../Resources.h" +#include "../Params.h" + +namespace serum +{ + +// =========================================================================== +// ADSR curve preview. +// =========================================================================== +class EnvelopeDisplay : public juce::Component +{ +public: + void setParams (float attack, float decay, float sustain, float release, float curve); + + void paint (juce::Graphics& g) override; + +private: + float attack = 0.05f, decay = 0.25f, sustain = 0.7f, release = 0.3f, curve = 0.5f; +}; + +} // namespace serum diff --git a/Source/GUI/FilterDisplay.cpp b/Source/GUI/FilterDisplay.cpp new file mode 100644 index 0000000..bbf87ba --- /dev/null +++ b/Source/GUI/FilterDisplay.cpp @@ -0,0 +1,79 @@ +#include "FilterDisplay.h" + +namespace serum +{ + +float FilterDisplay::magnitude (float freqHz, float cutoffHz, float res, int type) +{ + const float w = (cutoffHz > 1.0f) ? freqHz / cutoffHz : 1.0f; + const float q = 0.6f + res * 14.0f; + const float w2 = w * w; + const float denom = std::sqrt ((1.0f - w2) * (1.0f - w2) + (w / q) * (w / q)); + + switch ((FilterModel) type) + { + case FilterModel::LadderHP: return (denom > 1e-6f) ? w2 / denom : 0.0f; + case FilterModel::LadderBP: return (denom > 1e-6f) ? (w / q) / denom : 0.0f; + case FilterModel::Screamer: return (denom > 1e-6f) ? (w / (q * 0.7f)) / denom : 0.0f; + case FilterModel::Comb: + return 0.5f + 0.5f * std::cos (2.0f * juce::MathConstants::pi * freqHz / cutoffHz); + case FilterModel::Formant: + { + float m = 0.0f; + const float fc[3] = { cutoffHz * 0.5f, cutoffHz * 1.6f, cutoffHz * 3.2f }; + for (int i = 0; i < 3; ++i) + { + const float ww = freqHz / fc[i]; + const float dd = std::sqrt ((1.0f - ww * ww) * (1.0f - ww * ww) + (ww / q) * (ww / q)); + m += (ww / q) / (dd + 1e-6f); + } + return m / 3.0f; + } + case FilterModel::Diode: + default: + return (denom > 1e-6f) ? 1.0f / denom : 0.0f; + } +} + +void FilterDisplay::setParams (int t, float c, float r, float d, int s) +{ + type = t; cutoff = c; res = r; drive = d; slope = s; +} + +void FilterDisplay::paint (juce::Graphics& g) +{ + const auto b = getLocalBounds().toFloat(); + g.setColour (juce::Colours::black.withAlpha (0.4f)); + g.fillRoundedRectangle (b, 4.0f); + g.setColour (theme::outline); + g.drawRoundedRectangle (b.reduced (0.5f), 4.0f, 1.0f); + + if (! enabled) + return; + + const float cutoffHz = maps::cutoffToHz (cutoff); + const int n = 300; + juce::Path path; + path.startNewSubPath (b.getX() + 4.0f, b.getBottom() - 4.0f); + + for (int i = 0; i <= n; ++i) + { + const float logF = std::log10 (20.0f) + ((float) i / (float) n) * (std::log10 (20000.0f) - std::log10 (20.0f)); + const float freq = std::pow (10.0f, logF); + const float mag = magnitude (freq, cutoffHz, res, type); + const float db = juce::Decibels::gainToDecibels (mag + 1e-5f); + const float x = b.getX() + 4.0f + ((float) i / (float) n) * (b.getWidth() - 8.0f); + const float y = juce::jmap (db, -30.0f, 12.0f, b.getBottom() - 4.0f, b.getY() + 4.0f); + path.lineTo (x, y); + } + + path.lineTo (b.getRight() - 4.0f, b.getBottom() - 4.0f); + path.closeSubPath(); + + g.setColour (theme::accent.withAlpha (0.25f)); + g.fillPath (path); + g.setColour (theme::accent); + g.strokePath (path, juce::PathStrokeType (1.5f)); +} + +} // namespace serum diff --git a/Source/GUI/FilterDisplay.h b/Source/GUI/FilterDisplay.h new file mode 100644 index 0000000..c4f2ee3 --- /dev/null +++ b/Source/GUI/FilterDisplay.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include "../Resources.h" +#include "../Params.h" + +namespace serum +{ + +// =========================================================================== +// Filter frequency-response view (magnitude vs log frequency). +// =========================================================================== +class FilterDisplay : public juce::Component +{ +public: + void setParams (int type, float cutoff, float res, float drive, int slope); + void setEnabled (bool e) { enabled = e; } + + void paint (juce::Graphics& g) override; + + static float magnitude (float freqHz, float cutoffHz, float res, int type); + +private: + int type = 0; + float cutoff = 0.5f, res = 0.0f, drive = 0.0f; + int slope = 2; + bool enabled = true; +}; + +} // namespace serum diff --git a/Source/GUI/Knob.cpp b/Source/GUI/Knob.cpp new file mode 100644 index 0000000..05914af --- /dev/null +++ b/Source/GUI/Knob.cpp @@ -0,0 +1,24 @@ +#include "Knob.h" + +namespace serum +{ + +Knob::Knob (const juce::String& name, std::function fmt) + : formatter (std::move (fmt)) +{ + setSliderStyle (juce::Slider::RotaryVerticalDrag); + setTextBoxStyle (juce::Slider::NoTextBox, false, 0, 0); + setRange (0.0, 1.0); + setDoubleClickReturnValue (true, 0.5); + setName (name); + setComponentID (name); +} + +juce::String Knob::getTextFromValue (double value) +{ + if (formatter) + return formatter ((float) value); + return formatPercent ((float) value); +} + +} // namespace serum diff --git a/Source/GUI/Knob.h b/Source/GUI/Knob.h new file mode 100644 index 0000000..75cd11f --- /dev/null +++ b/Source/GUI/Knob.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include "../Resources.h" + +namespace serum +{ + +// =========================================================================== +// Rotary knob — a juce::Slider styled by SerumLookAndFeel. The value readout is +// formatted by a user-provided function via getTextFromValue(). +// =========================================================================== +class Knob : public juce::Slider +{ +public: + explicit Knob (const juce::String& name, + std::function formatter = {}); + + void setFormatter (std::function f) { formatter = std::move (f); } + + juce::String getTextFromValue (double value) override; + +private: + std::function formatter; +}; + +} // namespace serum diff --git a/Source/GUI/LFODisplay.cpp b/Source/GUI/LFODisplay.cpp new file mode 100644 index 0000000..a3f9e4e --- /dev/null +++ b/Source/GUI/LFODisplay.cpp @@ -0,0 +1,128 @@ +#include "LFODisplay.h" + +namespace serum +{ + +void LFODisplay::setShapeData (const std::vector& data, int s) +{ + steps = juce::jlimit (2, 64, s); + shapeData = data; + if ((int) shapeData.size() < 64) + shapeData.resize (64, 0.0f); + repaint(); +} + +float LFODisplay::valueAt (float phase) const noexcept +{ + switch ((LfoShape) shape) + { + case LfoShape::Sine: return std::sin (phase * 6.2831853f); + case LfoShape::Triangle: return 1.0f - 4.0f * std::abs (phase - 0.5f); + case LfoShape::Saw: return 2.0f * phase - 1.0f; + case LfoShape::Square: return (phase < 0.5f) ? 1.0f : -1.0f; + case LfoShape::SampleHold: + { + const int step = juce::jlimit (0, 15, (int) (phase * 16.0f)); + return ((step * 2654435761u) & 0xffffu) / 32768.0f - 1.0f; + } + case LfoShape::StepSeq: + { + const int idx = juce::jlimit (0, steps - 1, (int) (phase * steps)); + return shapeData.empty() ? 0.0f : shapeData[(size_t) idx]; + } + case LfoShape::Freehand: + { + if (shapeData.empty()) return 0.0f; + const float pos = phase * (float) (steps - 1); + const int i0 = (int) pos; + const int i1 = juce::jmin (i0 + 1, steps - 1); + const float frac = pos - (float) i0; + return shapeData[(size_t) i0] * (1.0f - frac) + shapeData[(size_t) i1] * frac; + } + default: return 0.0f; + } +} + +void LFODisplay::paint (juce::Graphics& g) +{ + const auto b = getLocalBounds().toFloat(); + g.setColour (juce::Colours::black.withAlpha (0.4f)); + g.fillRoundedRectangle (b, 4.0f); + g.setColour (theme::outline); + g.drawRoundedRectangle (b.reduced (0.5f), 4.0f, 1.0f); + + const float midY = b.getCentreY(); + g.setColour (theme::outline); + g.drawLine (b.getX() + 4.0f, midY, b.getRight() - 4.0f, midY, 1.0f); + + // Editable shapes show step markers. + if (shape == (int) LfoShape::StepSeq) + { + const float stepW = (b.getWidth() - 8.0f) / (float) steps; + for (int i = 0; i < steps; ++i) + { + const float v = shapeData.empty() ? 0.0f : shapeData[(size_t) i]; + const float x = b.getX() + 4.0f + stepW * i; + const float y = midY - v * (b.getHeight() * 0.42f); + g.setColour (theme::amber); + g.fillRect (x + 1.0f, juce::jmin (y, midY), stepW - 2.0f, std::abs (midY - y)); + } + } + + juce::Path path; + path.startNewSubPath (b.getX() + 4.0f, midY); + const int n = 256; + for (int i = 0; i <= n; ++i) + { + const float phase = (float) i / (float) n; + const float v = valueAt (phase); + const float x = b.getX() + 4.0f + phase * (b.getWidth() - 8.0f); + const float y = midY - v * (b.getHeight() * 0.42f); + if (i == 0) path.startNewSubPath (x, y); + else path.lineTo (x, y); + } + g.setColour (theme::green); + g.strokePath (path, juce::PathStrokeType (1.5f)); +} + +void LFODisplay::editAt (float x, float y) +{ + const auto b = getLocalBounds().toFloat(); + const float midY = b.getCentreY(); + const float normX = juce::jlimit (0.0f, 1.0f, (x - b.getX() - 4.0f) / (b.getWidth() - 8.0f)); + const float v = juce::jlimit (-1.0f, 1.0f, (midY - y) / (b.getHeight() * 0.42f)); + + if (shapeData.empty()) + shapeData.assign (64, 0.0f); + + if (shape == (int) LfoShape::StepSeq) + { + const int idx = juce::jlimit (0, steps - 1, (int) (normX * steps)); + shapeData[(size_t) idx] = v; + } + else if (shape == (int) LfoShape::Freehand) + { + const int idx = juce::jlimit (0, steps - 1, (int) (normX * steps)); + shapeData[(size_t) idx] = v; + } + else + { + return; + } + + if (onEdited) + onEdited (shapeData, steps); + repaint(); +} + +void LFODisplay::mouseDown (const juce::MouseEvent& e) +{ + editAt (e.x, e.y); +} + +void LFODisplay::mouseDrag (const juce::MouseEvent& e) +{ + editAt (e.x, e.y); +} + +} // namespace serum diff --git a/Source/GUI/LFODisplay.h b/Source/GUI/LFODisplay.h new file mode 100644 index 0000000..bc8f137 --- /dev/null +++ b/Source/GUI/LFODisplay.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include "../Resources.h" +#include "../Params.h" + +namespace serum +{ + +// =========================================================================== +// LFO shape view. Step-sequencer and freehand shapes are click/drag editable. +// =========================================================================== +class LFODisplay : public juce::Component +{ +public: + void setShape (int s) { shape = s; repaint(); } + void setShapeData (const std::vector& data, int steps); + void setOnShapeEdited (std::function&, int)> cb) { onEdited = std::move (cb); } + + void paint (juce::Graphics& g) override; + void mouseDown (const juce::MouseEvent& e) override; + void mouseDrag (const juce::MouseEvent& e) override; + +private: + int shape = 0; + std::vector shapeData; + int steps = 16; + std::function&, int)> onEdited; + + float valueAt (float phase) const noexcept; + void editAt (float x, float y); +}; + +} // namespace serum diff --git a/Source/GUI/Panel.cpp b/Source/GUI/Panel.cpp new file mode 100644 index 0000000..0c8e7bf --- /dev/null +++ b/Source/GUI/Panel.cpp @@ -0,0 +1,26 @@ +#include "Panel.h" + +namespace serum +{ + +Panel::Panel (const juce::String& t) : title (t) +{ +} + +void Panel::paint (juce::Graphics& g) +{ + const auto b = getLocalBounds().toFloat(); + g.setColour (theme::panel); + g.fillRoundedRectangle (b, 8.0f); + g.setColour (theme::outline); + g.drawRoundedRectangle (b.reduced (0.5f), 8.0f, 1.0f); + + if (title.isNotEmpty()) + { + g.setColour (theme::textDim); + g.setFont (juce::Font (11.0f, juce::Font::bold)); + g.drawText (title, 10, 6, getWidth() - 20, 16, juce::Justification::left, false); + } +} + +} // namespace serum diff --git a/Source/GUI/Panel.h b/Source/GUI/Panel.h new file mode 100644 index 0000000..5dea1a4 --- /dev/null +++ b/Source/GUI/Panel.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include "../Resources.h" + +namespace serum +{ + +// =========================================================================== +// Rounded dark panel with an optional title bar. +// =========================================================================== +class Panel : public juce::Component +{ +public: + explicit Panel (const juce::String& title = {}); + + void paint (juce::Graphics& g) override; + +private: + juce::String title; +}; + +} // namespace serum diff --git a/Source/GUI/SerumLookAndFeel.h b/Source/GUI/SerumLookAndFeel.h new file mode 100644 index 0000000..e921d08 --- /dev/null +++ b/Source/GUI/SerumLookAndFeel.h @@ -0,0 +1,156 @@ +#pragma once + +#include +#include "../Resources.h" + +namespace serum +{ + +// =========================================================================== +// Dark vector look-and-feel. Rotary and linear sliders are drawn with juce::Path +// (arcs + indicator + thumb) so they stay crisp at every UI scale. +// =========================================================================== +class SerumLookAndFeel : public juce::LookAndFeel_V4 +{ +public: + SerumLookAndFeel() + { + setColour (juce::Slider::backgroundColourId, theme::panel); + setColour (juce::Slider::thumbColourId, theme::accent); + setColour (juce::Slider::trackColourId, theme::outline); + setColour (juce::Slider::rotarySliderFillColourId, theme::accent); + setColour (juce::Slider::rotarySliderOutlineColourId, theme::panelRaised); + setColour (juce::Slider::textBoxTextColourId, theme::text); + setColour (juce::Slider::textBoxBackgroundColourId, theme::panel); + setColour (juce::Slider::textBoxHighlightColourId, theme::accent); + setColour (juce::Slider::textBoxOutlineColourId, theme::outline); + + setColour (juce::ComboBox::backgroundColourId, theme::panelRaised); + setColour (juce::ComboBox::textColourId, theme::text); + setColour (juce::ComboBox::arrowColourId, theme::textDim); + setColour (juce::ComboBox::outlineColourId, theme::outline); + setColour (juce::ComboBox::buttonColourId, theme::panelRaised); + setColour (juce::ComboBox::focusedOutlineColourId, theme::accent); + setColour (juce::PopupMenu::backgroundColourId, theme::panelRaised); + setColour (juce::PopupMenu::textColourId, theme::text); + setColour (juce::PopupMenu::highlightedBackgroundColourId, theme::accent); + setColour (juce::PopupMenu::highlightedTextColourId, juce::Colours::black); + + setColour (juce::TextButton::buttonColourId, theme::panelRaised); + setColour (juce::TextButton::buttonOnColourId, theme::accent); + setColour (juce::TextButton::textColourOffId, theme::textDim); + setColour (juce::TextButton::textColourOnId, juce::Colours::black); + + setColour (juce::TextEditor::backgroundColourId, theme::panel); + setColour (juce::TextEditor::textColourId, theme::text); + setColour (juce::TextEditor::outlineColourId, theme::outline); + setColour (juce::TextEditor::focusedOutlineColourId, theme::accent); + } + + void drawRotarySlider (juce::Graphics& g, int x, int y, int width, int height, + float sliderPos, float, float, juce::Slider& slider) override + { + const int labelH = 18; + const int knobH = height - labelH; + const int knobSize = juce::jmin (width, knobH) - 6; + const int cx = x + width / 2; + const int cy = y + knobH / 2; + const float radius = knobSize * 0.5f; + + const juce::Rectangle area ((float) cx - radius, (float) cy - radius, + radius * 2.0f, radius * 2.0f); + + // Body. + g.setColour (theme::panelRaised); + g.fillEllipse (area); + g.setColour (theme::outline); + g.drawEllipse (area, 1.0f); + + const float startAngle = juce::MathConstants::pi * 1.25f; // 225 deg + const float endAngle = juce::MathConstants::pi * -0.25f; + const float valueAngle = startAngle + sliderPos * (endAngle - startAngle); + + // Track arc. + juce::Path track; + track.addCentredArc ((float) cx, (float) cy, radius - 4.0f, radius - 4.0f, + 0.0f, startAngle, endAngle, true); + g.setColour (theme::outline); + g.strokePath (track, juce::PathStrokeType (3.0f, juce::PathStrokeType::curved)); + + // Value arc. + juce::Path valueArc; + valueArc.addCentredArc ((float) cx, (float) cy, radius - 4.0f, radius - 4.0f, + 0.0f, startAngle, valueAngle, true); + g.setColour (theme::accent); + g.strokePath (valueArc, juce::PathStrokeType (3.0f, juce::PathStrokeType::curved)); + + // Indicator. + const float indX = (float) cx + (radius - 9.0f) * std::cos (valueAngle); + const float indY = (float) cy + (radius - 9.0f) * std::sin (valueAngle); + g.setColour (theme::text); + g.drawLine ((float) cx, (float) cy, indX, indY, 2.0f); + g.setColour (theme::text); + g.fillEllipse (indX - 2.0f, indY - 2.0f, 4.0f, 4.0f); + + // Value readout. + g.setColour (theme::text); + g.setFont (juce::Font (10.0f, juce::Font::bold)); + g.drawText (slider.getTextFromValue (slider.getValue()), + juce::Rectangle (x, y, width, knobH - (int) radius + 8), + juce::Justification::centred, false); + + // Name label. + g.setColour (theme::textDim); + g.setFont (juce::Font (11.0f)); + g.drawText (slider.getName(), juce::Rectangle (x, y + knobH, width, labelH), + juce::Justification::centred, false); + } + + void drawLinearSlider (juce::Graphics& g, int x, int y, int width, int height, + float sliderPos, float, float, juce::Slider::SliderStyle, juce::Slider& slider) override + { + const bool vertical = height > width; + juce::Rectangle track; + + if (vertical) + { + track = juce::Rectangle (x + width / 2 - 3, y + 8, 6, height - 16); + } + else + { + track = juce::Rectangle (x + 8, y + height / 2 - 3, width - 16, 6); + } + + g.setColour (theme::outline); + g.fillRoundedRectangle (track.toFloat(), 3.0f); + + juce::Rectangle fill; + if (vertical) + { + const float h = track.getHeight() * sliderPos; + fill = juce::Rectangle ((float) track.getX(), (float) track.getBottom() - h, + (float) track.getWidth(), h); + } + else + { + fill = juce::Rectangle ((float) track.getX(), (float) track.getY(), + track.getWidth() * sliderPos, (float) track.getHeight()); + } + g.setColour (theme::accent); + g.fillRoundedRectangle (fill, 3.0f); + + // Thumb. + const float thumbC = vertical ? (track.getBottom() - track.getHeight() * sliderPos) + : (track.getX() + track.getWidth() * sliderPos); + juce::Point thumb (vertical ? (float) track.getCentreX() : thumbC, + vertical ? thumbC : (float) track.getCentreY()); + g.setColour (theme::text); + g.fillEllipse (thumb.x - 6.0f, thumb.y - 6.0f, 12.0f, 12.0f); + + g.setColour (theme::textDim); + g.setFont (juce::Font (10.0f)); + g.drawText (slider.getName(), x, y, width, height, juce::Justification::bottomLeft, false); + } +}; + +} // namespace serum diff --git a/Source/GUI/Slider.cpp b/Source/GUI/Slider.cpp new file mode 100644 index 0000000..5f5b5d6 --- /dev/null +++ b/Source/GUI/Slider.cpp @@ -0,0 +1,13 @@ +#include "Slider.h" + +namespace serum +{ + +Slider::Slider (const juce::String& name, std::function formatter) + : Knob (name, std::move (formatter)) +{ + setSliderStyle (juce::Slider::LinearHorizontal); + setTextBoxStyle (juce::Slider::NoTextBox, false, 0, 0); +} + +} // namespace serum diff --git a/Source/GUI/Slider.h b/Source/GUI/Slider.h new file mode 100644 index 0000000..8e0b853 --- /dev/null +++ b/Source/GUI/Slider.h @@ -0,0 +1,18 @@ +#pragma once + +#include "Knob.h" + +namespace serum +{ + +// =========================================================================== +// Horizontal slider (same formatting machinery as Knob). +// =========================================================================== +class Slider : public Knob +{ +public: + explicit Slider (const juce::String& name, + std::function formatter = {}); +}; + +} // namespace serum diff --git a/Source/GUI/ToggleButton.cpp b/Source/GUI/ToggleButton.cpp new file mode 100644 index 0000000..614e63e --- /dev/null +++ b/Source/GUI/ToggleButton.cpp @@ -0,0 +1,71 @@ +#include "ToggleButton.h" + +namespace serum +{ + +ToggleButton::ToggleButton (const juce::String& lbl) : label (lbl) +{ + setRepaintsOnMouseActivity (true); +} + +void ToggleButton::attach (juce::AudioProcessorValueTreeState& apvts, const juce::String& paramId) +{ + param = apvts.getParameter (paramId); + if (param != nullptr) + { + state = param->getValue() > 0.5f; + attachment = std::make_unique (*param, + [this] (float newValue) { setToggleState (newValue > 0.5f); }); + } +} + +void ToggleButton::setToggleState (bool s) +{ + if (state == s) + return; + state = s; + repaint(); +} + +void ToggleButton::paint (juce::Graphics& g) +{ + const auto bounds = getLocalBounds().toFloat(); + const float ledSize = juce::jmin (bounds.getHeight() - 16.0f, 16.0f); + const juce::Rectangle led ((bounds.getWidth() - ledSize) * 0.5f, 4.0f, ledSize, ledSize); + + if (state) + { + g.setColour (onColour.withAlpha (0.35f)); + g.fillEllipse (led.expanded (6.0f)); + g.setColour (onColour); + } + else + { + g.setColour (theme::outline); + } + g.fillEllipse (led); + g.setColour (state ? juce::Colours::black : theme::textDim); + g.drawEllipse (led, 1.0f); + + g.setColour (state ? theme::text : theme::textDim); + g.setFont (juce::Font (11.0f)); + g.drawText (label, 0, (int) (led.getBottom() + 2), getWidth(), 14, juce::Justification::centred, false); +} + +void ToggleButton::mouseDown (const juce::MouseEvent&) +{ + const bool newState = ! state; + + if (onClick) + { + onClick (newState); + } + else if (param != nullptr) + { + param->setValueNotifyingHost (newState ? 1.0f : 0.0f); + } + + setToggleState (newState); +} + +} // namespace serum diff --git a/Source/GUI/ToggleButton.h b/Source/GUI/ToggleButton.h new file mode 100644 index 0000000..bc4b6b8 --- /dev/null +++ b/Source/GUI/ToggleButton.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include "../Resources.h" + +namespace serum +{ + +// =========================================================================== +// LED-style toggle (power) button that drives a float parameter (0/1) or an +// optional callback (used by RAVE). +// =========================================================================== +class ToggleButton : public juce::Component +{ +public: + explicit ToggleButton (const juce::String& label = {}); + + void attach (juce::AudioProcessorValueTreeState& apvts, const juce::String& paramId); + void setOnClick (std::function cb) { onClick = std::move (cb); } + + void setOnColour (juce::Colour c) { onColour = c; repaint(); } + void setLabel (const juce::String& lbl) { label = lbl; repaint(); } + + void setToggleState (bool s); + bool getToggleState() const noexcept { return state; } + + void paint (juce::Graphics& g) override; + void mouseDown (const juce::MouseEvent&) override; + +private: + bool state = false; + juce::String label; + juce::RangedAudioParameter* param = nullptr; + std::unique_ptr attachment; + std::function onClick; + juce::Colour onColour = theme::accent; +}; + +} // namespace serum diff --git a/Source/GUI/WaveformDisplay.cpp b/Source/GUI/WaveformDisplay.cpp new file mode 100644 index 0000000..65fa2c3 --- /dev/null +++ b/Source/GUI/WaveformDisplay.cpp @@ -0,0 +1,45 @@ +#include "WaveformDisplay.h" + +namespace serum +{ + +void WaveformDisplay::paint (juce::Graphics& g) +{ + const auto b = getLocalBounds().toFloat(); + g.setColour (juce::Colours::black.withAlpha (0.4f)); + g.fillRoundedRectangle (b, 4.0f); + g.setColour (theme::outline); + g.drawRoundedRectangle (b.reduced (0.5f), 4.0f, 1.0f); + + const float midY = b.getCentreY(); + g.setColour (theme::outline); + g.drawLine (b.getX() + 4.0f, midY, b.getRight() - 4.0f, midY, 1.0f); + + if (wtLib == nullptr || ! enabled) + return; + + const Wavetable& wt = wtLib->getTable (wave); + if (! wt.isValid()) + return; + + const int n = 512; + juce::Path path; + path.startNewSubPath (b.getX() + 4.0f, midY); + + for (int i = 0; i <= n; ++i) + { + const float phase = (float) i / (float) n; + const float sample = wt.readSafe (wtPos * 255.0f, phase); + const float x = b.getX() + 4.0f + ((float) i / (float) n) * (b.getWidth() - 8.0f); + const float y = midY - sample * (b.getHeight() * 0.42f); + if (i == 0) + path.startNewSubPath (x, y); + else + path.lineTo (x, y); + } + + g.setColour (theme::accent); + g.strokePath (path, juce::PathStrokeType (1.5f)); +} + +} // namespace serum diff --git a/Source/GUI/WaveformDisplay.h b/Source/GUI/WaveformDisplay.h new file mode 100644 index 0000000..d5faa77 --- /dev/null +++ b/Source/GUI/WaveformDisplay.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include "../Resources.h" +#include "../Wavetable.h" + +namespace serum +{ + +// =========================================================================== +// Single-cycle wavetable view (morphs with the WT position knob). +// =========================================================================== +class WaveformDisplay : public juce::Component +{ +public: + void setWavetables (const WavetableLibrary* lib) { wtLib = lib; } + void setWaveIndex (int index) { wave = index; } + void setFramePosition (float pos) { wtPos = pos; } + void setEnabled (bool e) { enabled = e; } + + void paint (juce::Graphics& g) override; + +private: + const WavetableLibrary* wtLib = nullptr; + int wave = 0; + float wtPos = 0.0f; + bool enabled = true; +}; + +} // namespace serum