Replace nav buttons with a virtual joystick pad

This commit is contained in:
Armin 2026-10-09 17:58:07 +02:00
commit c7ebd3ed4d
6 changed files with 418 additions and 76 deletions

View file

@ -54,6 +54,7 @@ target_sources(SM-26 PRIVATE
Source/Presets.cpp
Source/LcdDisplay.cpp
Source/MenuSystem.cpp
Source/JoystickPad.cpp
)
target_include_directories(SM-26 PRIVATE Source)

287
Source/JoystickPad.cpp Normal file
View file

@ -0,0 +1,287 @@
#include "JoystickPad.h"
#include <cmath>
namespace {
constexpr float kPi = juce::MathConstants<float>::pi;
float cardinalAngle (int direction)
{
switch (direction)
{
case JoystickPad::Right: return 0.0f;
case JoystickPad::Down: return kPi * 0.5f;
case JoystickPad::Left: return kPi;
case JoystickPad::Up: return -kPi * 0.5f;
default: return 0.0f;
}
}
juce::Path withCentre (juce::Path p, float cx, float cy)
{
auto bounds = p.getBounds();
p.applyTransform (juce::AffineTransform::translation (cx - bounds.getCentreX(),
cy - bounds.getCentreY()));
return p;
}
// Triangle pointing "up" (in screen space) centred on the origin.
juce::Path arrowUp (float s)
{
juce::Path p;
p.addTriangle (0.0f, -s * 0.90f,
-s * 0.55f, s * 0.55f,
s * 0.55f, s * 0.55f);
return p;
}
void drawArrow (juce::Graphics& g, float cx, float cy, int direction,
float r, juce::Colour col)
{
auto p = arrowUp (r);
float angle = cardinalAngle (direction);
p.applyTransform (juce::AffineTransform::rotation (angle + kPi * 0.5f, 0.0f, 0.0f));
p = withCentre (p, cx, cy);
g.setColour (col);
g.fillPath (p);
}
} // namespace
JoystickPad::JoystickPad()
{
setOpaque (true);
}
void JoystickPad::setRepeatSpeed (int initialDelayMs_, int repeatMs_)
{
initialDelayMs = initialDelayMs_;
repeatMs = repeatMs_;
}
int JoystickPad::directionFrom (const juce::MouseEvent& e) const
{
const float w = (float) getWidth();
const float h = (float) getHeight();
const float cx = w * 0.5f;
const float cy = h * 0.5f;
const float wr = juce::jmin (w, h) * 0.5f * 0.78f;
const float dx = e.position.x - cx;
const float dy = e.position.y - cy;
const float rad = std::hypot (dy, dx);
if (rad < wr * 0.12f)
return None;
const float a = std::atan2 (dy, dx); // -pi..pi, y grows downwards
const float oct = kPi * 0.25f;
if (a >= -oct && a < oct) return Right;
if (a >= oct && a < 3.0f * oct) return Down;
if (a >= 3.0f * oct || a < -3.0f * oct) return Left;
return Up;
}
void JoystickPad::beginHold (int direction)
{
active = (Direction) direction;
holdStartMs = juce::Time::getMillisecondCounter();
lastFireMs = holdStartMs;
repeatsStarted = false;
if (onAction)
onAction (direction);
startTimer (16);
repaint();
}
void JoystickPad::endHold()
{
active = None;
stopTimer();
repaint();
}
void JoystickPad::mouseDown (const juce::MouseEvent& e)
{
const int d = directionFrom (e);
if (d != None)
beginHold (d);
else
endHold();
}
void JoystickPad::mouseDrag (const juce::MouseEvent& e)
{
const int d = directionFrom (e);
if (d != None && d != (int) active)
beginHold (d);
else if (d == None && active != None)
endHold();
}
void JoystickPad::mouseUp (const juce::MouseEvent&)
{
endHold();
}
void JoystickPad::timerCallback()
{
if (active == None)
{
stopTimer();
return;
}
const int64 now = juce::Time::getMillisecondCounter();
if (! repeatsStarted)
{
if (now - lastFireMs >= initialDelayMs)
{
repeatsStarted = true;
lastFireMs = now;
if (onAction)
onAction ((int) active);
}
}
else if (now - lastFireMs >= repeatMs)
{
lastFireMs = now;
if (onAction)
onAction ((int) active);
}
}
void JoystickPad::paint (juce::Graphics& g)
{
const float w = (float) getWidth();
const float h = (float) getHeight();
const float cx = w * 0.5f;
const float cy = h * 0.5f;
const float bezelR = juce::jmin (w, h) * 0.5f - 1.0f;
const float wellR = bezelR * 0.78f;
// Clear the full component first (opaque component: every pixel must be
// repainted or stale content tears/flickers during redraws).
g.fillAll (juce::Colour (0xFF000000));
// Ambient under-shadow (soft, offset downward).
{
float alpha = 0.5f;
for (int i = 3; i >= 1; --i)
{
g.setColour (juce::Colour::fromFloatRGBA (0.0f, 0.0f, 0.0f, alpha / (float) i));
g.fillEllipse (cx - bezelR - (float) i, cy - bezelR - (float) i + 3.0f,
2.0f * (bezelR + (float) i), 2.0f * (bezelR + (float) i));
}
}
// Outer bezel: soft radial metal.
{
juce::ColourGradient bezel (juce::Colour (0xFF2E2E2E), cx - bezelR * 0.35f, cy - bezelR * 0.35f,
juce::Colour (0xFF0A0A0A), cx + bezelR * 0.45f, cy + bezelR * 0.45f, true);
g.setGradientFill (bezel);
g.fillEllipse (cx - bezelR, cy - bezelR, 2.0f * bezelR, 2.0f * bezelR);
}
// Bezel rim shading for a 3D ring (dark only - no bright top-light arc).
{
auto ring = [&](float r0, float r1)
{
juce::Path p;
p.addEllipse (cx - r1, cy - r1, r1 * 2.0f, r1 * 2.0f);
p.addEllipse (cx - r0, cy - r0, r0 * 2.0f, r0 * 2.0f);
p.setUsingNonZeroWinding (false);
return p;
};
g.setColour (juce::Colour (0xFF050505));
g.fillPath (ring (bezelR * 0.93f, bezelR));
}
// Recessed well (flat).
{
g.setColour (juce::Colour (0xFF0A0A0A));
g.fillEllipse (cx - wellR, cy - wellR, 2.0f * wellR, 2.0f * wellR);
}
auto wedgePath = [&](int direction) -> juce::Path
{
// JUCE measures pie/arc angles clockwise from 12 o'clock, so add
// 90 degrees to our visual heading (0 = right, +90 = down).
const float a = cardinalAngle (direction) + kPi * 0.5f;
const float start = a - kPi * 0.25f;
const float end = a + kPi * 0.25f;
juce::Path p;
p.addPieSegment (cx - wellR, cy - wellR, wellR * 2.0f, wellR * 2.0f,
start, end, 0.0f);
return p;
};
// Quarter-button wedges (flat).
for (int d = Up; d <= Right; ++d)
{
auto p = wedgePath (d);
g.setColour (juce::Colour (0xFF151515));
g.fillPath (p);
g.setColour (juce::Colour (0xFF242424));
g.strokePath (p, juce::PathStrokeType (1.0f));
}
// Flat seams between the quarters (a single dark channel).
for (int i = 0; i < 4; ++i)
{
const float a = juce::degreesToRadians (45.0f + 90.0f * (float) i);
const float dx = std::cos (a);
const float dy = std::sin (a);
g.setColour (juce::Colour (0xFF050505));
g.drawLine (cx + dx * 2.0f, cy + dy * 2.0f, cx + dx * (wellR - 2.0f), cy + dy * (wellR - 2.0f), 2.0f);
}
// Resting glyphs on the four quarters.
for (int d = Up; d <= Right; ++d)
if (d != (int) active)
drawArrow (g, cx + std::cos (cardinalAngle (d)) * wellR * 0.55f,
cy + std::sin (cardinalAngle (d)) * wellR * 0.55f,
d, wellR * 0.16f, juce::Colour (0xFF454545));
// Lit quarter (active direction) - flat bright fill, black glyph.
if (active != None)
{
auto p = wedgePath ((int) active);
g.setColour (juce::Colour (0xFFECECEC));
g.fillPath (p);
drawArrow (g, cx + std::cos (cardinalAngle ((int) active)) * wellR * 0.55f,
cy + std::sin (cardinalAngle ((int) active)) * wellR * 0.55f,
(int) active, wellR * 0.16f, juce::Colour (0xFF0A0A0A));
}
// Center cap that ties the four quarters together (static, no stick).
{
const float sr = wellR * 0.30f;
juce::ColourGradient cap (juce::Colour (0xFF181818), cx, cy - sr,
juce::Colour (0xFF080808), cx, cy + sr, false);
g.setGradientFill (cap);
g.fillEllipse (cx - sr, cy - sr, sr * 2.0f, sr * 2.0f);
g.setColour (juce::Colour (0xFF060606));
g.drawEllipse (cx - sr, cy - sr, sr * 2.0f, sr * 2.0f, 1.0f);
g.setColour (juce::Colour (0xFF2A2A2A));
g.drawEllipse (cx - sr, cy - sr, sr * 2.0f, sr * 2.0f, 0.5f);
g.setColour (juce::Colour (0xFF3A3A3A));
g.drawEllipse (cx + sr * 0.30f, cy + sr * 0.20f, sr * 0.28f, sr * 0.28f, 2.0f);
}
// Mechanical screws around the bezel.
for (int i = 0; i < 4; ++i)
{
const float a = juce::degreesToRadians (45.0f + 90.0f * (float) i);
const float srx = cx + std::cos (a) * bezelR * 0.92f;
const float sry = cy + std::sin (a) * bezelR * 0.92f;
const float sr0 = 2.0f;
g.setColour (juce::Colour (0xFF222222));
g.fillEllipse (srx - sr0, sry - sr0, sr0 * 2.0f, sr0 * 2.0f);
g.setColour (juce::Colour (0xFF080808));
g.fillEllipse (srx - sr0 * 0.4f, sry - sr0 * 0.4f, sr0 * 0.8f, sr0 * 0.8f);
}
}

70
Source/JoystickPad.h Normal file
View file

@ -0,0 +1,70 @@
#pragma once
#include <JuceHeader.h>
// Small flat circular button matching the joystick pad's dark style.
class RoundNavButton : public juce::Button
{
public:
explicit RoundNavButton (const juce::String& label) : juce::Button (label) {}
void paintButton (juce::Graphics& g, bool isDown, bool isOver) override
{
const float w = (float) getWidth();
const float h = (float) getHeight();
const float cx = w * 0.5f;
const float cy = h * 0.5f;
const float r = juce::jmin (w, h) * 0.5f - 1.0f;
g.fillAll (juce::Colour (0xFF000000));
g.setColour (isDown ? juce::Colour (0xFFECECEC) : juce::Colour (0xFF151515));
g.fillEllipse (cx - r, cy - r, r * 2.0f, r * 2.0f);
g.setColour (isDown ? juce::Colour (0xFFECECEC) : juce::Colour (0xFF242424));
g.drawEllipse (cx - r, cy - r, r * 2.0f, r * 2.0f, 1.0f);
g.setFont (juce::Font (juce::FontOptions { juce::Font::getDefaultMonospacedFontName(),
12.0f, isDown ? juce::Font::bold : juce::Font::plain }));
g.setColour (isDown ? juce::Colour (0xFF0A0A0A) : juce::Colour (0xFF9A9A9A));
g.drawText (getButtonText(), juce::Rectangle<float> (0, 0, w, h), juce::Justification::centred);
}
};
// A flat dark virtual-analog joystick pad. A circular well holds four
// quarter-circle buttons (up/down/left/right). Holding a direction glows
// the matching quarter and emits repeat actions like a gamepad D-pad.
class JoystickPad : public juce::Component, private juce::Timer
{
public:
enum Direction : int { None = -1, Up = 0, Down = 1, Left = 2, Right = 3 };
JoystickPad();
~JoystickPad() override = default;
void paint (juce::Graphics&) override;
void mouseDown (const juce::MouseEvent&) override;
void mouseDrag (const juce::MouseEvent&) override;
void mouseUp (const juce::MouseEvent&) override;
// Called on press (immediately) and then on repeat while held.
std::function<void (int direction)> onAction;
void setRepeatSpeed (int initialDelayMs, int repeatMs);
private:
void timerCallback() override;
void beginHold (int direction);
void endHold();
int directionFrom (const juce::MouseEvent&) const;
Direction active = None;
int initialDelayMs = 250;
int repeatMs = 50;
int64 holdStartMs = 0;
int64 lastFireMs = 0;
bool repeatsStarted = false;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (JoystickPad)
};

View file

@ -240,8 +240,6 @@ void LcdDisplay::timerCallback() { repaint(); }
void LcdDisplay::mouseDown (const juce::MouseEvent& e)
{
const int row = juce::jlimit (0, kRows - 1,
(int) (e.position.y / (float) (getHeight() / kRows)));
if (onPressed)
onPressed (row);
(void)e;
// Clicking disabled - navigate with joystick keys only.
}

View file

@ -15,7 +15,7 @@ SM26AudioProcessorEditor::SM26AudioProcessorEditor (SM26AudioProcessor& p)
setResizeLimits (760, 620, 1500, 1300);
addAndMakeVisible (lcd);
lcd.onPressed = [this](int) { if (! menu.isOpen()) openPresetMenu(); };
lcd.onPressed = [](int) {};
setupKnobs();
setupNavButtons();
@ -69,38 +69,31 @@ void SM26AudioProcessorEditor::setupKnobs()
}
}
juce::Font SM26AudioProcessorEditor::NavButtonLookAndFeel::getTextButtonFont (juce::TextButton&, int)
{
return juce::Font (juce::FontOptions { juce::Font::getDefaultMonospacedFontName(),
10.5f, juce::Font::bold });
}
void SM26AudioProcessorEditor::setupNavButtons()
{
auto setup = [this](juce::TextButton& btn, juce::Colour bg)
addAndMakeVisible (navEnter);
addAndMakeVisible (navEsc);
addAndMakeVisible (navStick);
navStick.setRepeatSpeed (250, 50);
navStick.onAction = [this](int dir) {
switch (dir)
{
btn.setLookAndFeel (&navLookAndFeel);
btn.setColour (juce::TextButton::buttonColourId, bg);
btn.setColour (juce::TextButton::textColourOffId, juce::Colour (0xFFE0E0E0));
btn.setColour (juce::ComboBox::outlineColourId, juce::Colour (0xFF444444));
addAndMakeVisible (btn);
case JoystickPad::Up:
if (menu.isOpen()) menu.up();
else cyclePreset (-1);
break;
case JoystickPad::Down:
if (menu.isOpen()) menu.down();
else cyclePreset (+1);
break;
case JoystickPad::Left: menu.left(); break;
case JoystickPad::Right: menu.right(); break;
default: break;
}
};
setup (navUp, juce::Colour (0xFF000000));
setup (navDown, juce::Colour (0xFF000000));
setup (navLeft, juce::Colour (0xFF000000));
setup (navRight, juce::Colour (0xFF000000));
setup (navEnter, juce::Colour (0xFF000000));
setup (navEsc, juce::Colour (0xFF000000));
navUp.setTriggeredOnMouseDown (true);
navUp.setRepeatSpeed (250, 50);
navDown.setTriggeredOnMouseDown (true);
navDown.setRepeatSpeed (250, 50);
navUp.onClick = [this] { if (menu.isOpen()) menu.up(); else cyclePreset (-1); };
navDown.onClick = [this] { if (menu.isOpen()) menu.down(); else cyclePreset (+1); };
navLeft.onClick = [this] { menu.left(); };
navRight.onClick = [this] { menu.right(); };
navEnter.onClick = [this] { if (menu.isOpen()) menu.enter(); else menu.open(); };
navEsc.onClick = [this] { if (menu.isOpen()) menu.escape(); else menu.close(); };
}
@ -332,45 +325,45 @@ void SM26AudioProcessorEditor::resized()
{
const float totalW = (float) getWidth();
const float totalH = (float) getHeight();
const float footerTop = totalH - 22.0f;
float knobSize = 78.0f;
float knobGap = 20.0f;
float knobsW = 4.0f * knobSize + 3.0f * knobGap;
float knobsX = (totalW - knobsW) / 2.0f;
float knobsY = juce::jmax (360.0f, totalH - 300.0f);
// ---- LCD -----------------------------------------------------------
lcd.setBounds (14, 16, (int) totalW - 28, 302);
const float lcdBottom = 16.0f + 302.0f;
// ---- navigation joystick: directly under the LCD --------------------
const float navSize = 30.0f;
const float navGap = 3.0f;
const float navCenterX = totalW / 2.0f;
const float navTop = lcdBottom + 12.0f;
const float stickS = juce::jlimit (64.0f, 116.0f, juce::jmin (116.0f, totalW * 0.35f));
const float stickLeft = navCenterX - stickS * 0.5f;
navStick.setBounds ((int) stickLeft, (int) navTop, (int) stickS, (int) stickS);
navEnter.setBounds ((int) (stickLeft - navSize - navGap),
(int) (navTop + stickS * 0.5f - navSize * 0.5f),
(int) navSize, (int) navSize);
navEsc.setBounds ((int) (stickLeft + stickS + navGap),
(int) (navTop + stickS * 0.5f - navSize * 0.5f),
(int) navSize, (int) navSize);
// ---- knobs: below the navigation -----------------------------------
const float knobSize = 62.0f;
const float knobGap = 14.0f;
const float knobsW = 4.0f * knobSize + 3.0f * knobGap;
const float knobsX = (totalW - knobsW) / 2.0f;
float knobsY = navTop + stickS + 26.0f;
knobsY = juce::jmin (knobsY, footerTop - (knobSize + 31.0f) - 10.0f);
for (int i = 0; i < 4; ++i)
{
float x = knobsX + (float) i * (knobSize + knobGap);
knobSliders[i].setBounds ((int) x, (int) knobsY, (int) knobSize, (int) knobSize);
knobLabels[i].setBounds ((int) x, (int) (knobsY + knobSize + 2), (int) knobSize, 18);
knobValueLabels[i].setBounds ((int) x, (int) (knobsY + knobSize + 20), (int) knobSize, 16);
knobLabels[i].setBounds ((int) x, (int) (knobsY + knobSize + 2), (int) knobSize, 14);
knobValueLabels[i].setBounds ((int) x, (int) (knobsY + knobSize + 16), (int) knobSize, 15);
}
float navSize = 30.0f;
float navGap = 3.0f;
float navCenterX = totalW / 2.0f;
float legendBottom = knobsY + knobSize + 20.0f + 16.0f;
float footerTop = totalH - 22.0f;
float navCenterY = (legendBottom + footerTop) * 0.5f;
navUp.setBounds ((int) (navCenterX - navSize / 2), (int) (navCenterY - navSize - navGap),
(int) navSize, (int) navSize);
navDown.setBounds ((int) (navCenterX - navSize / 2), (int) (navCenterY + navGap),
(int) navSize, (int) navSize);
navLeft.setBounds ((int) (navCenterX - navSize - navGap - navSize / 2), (int) (navCenterY - navSize / 2),
(int) navSize, (int) navSize);
navRight.setBounds ((int) (navCenterX + navGap + navSize / 2), (int) (navCenterY - navSize / 2),
(int) navSize, (int) navSize);
navEnter.setBounds ((int) (navCenterX - navSize - navGap), (int) (navCenterY + navSize + navGap),
(int) navSize, (int) navSize);
navEsc.setBounds ((int) (navCenterX + navGap), (int) (navCenterY + navSize + navGap),
(int) navSize, (int) navSize);
lcd.setBounds (14, 16, (int) totalW - 28, 302);
footer.setBounds (8, (int) totalH - 22, (int) totalW - 16, 16);
footer.setBounds (8, (int) footerTop, (int) totalW - 16, 16);
}
void SM26AudioProcessorEditor::timerCallback()

View file

@ -4,6 +4,7 @@
#include "PluginProcessor.h"
#include "LcdDisplay.h"
#include "MenuSystem.h"
#include "JoystickPad.h"
#include "Presets.h"
#include "SynthParams.h"
@ -34,12 +35,6 @@ private:
SM26AudioProcessor& proc;
MenuSystem menu;
class NavButtonLookAndFeel : public juce::LookAndFeel_V4
{
public:
juce::Font getTextButtonFont (juce::TextButton&, int) override;
} navLookAndFeel;
LcdDisplay lcd;
bool knobDragging[4] = {};
@ -48,12 +43,10 @@ private:
juce::Label knobLabels[4];
juce::Label knobValueLabels[4];
juce::TextButton navUp { "UP" };
juce::TextButton navDown { "DN" };
juce::TextButton navLeft { "LT" };
juce::TextButton navRight { "RT" };
juce::TextButton navEnter { "OK" };
juce::TextButton navEsc { "ESC" };
RoundNavButton navEnter { "OK" };
RoundNavButton navEsc { "ESC" };
JoystickPad navStick;
juce::Label footer;