From c7ebd3ed4d83f25867822d7e78115c86fa275514 Mon Sep 17 00:00:00 2001 From: Armin Date: Fri, 9 Oct 2026 17:58:07 +0200 Subject: [PATCH] Replace nav buttons with a virtual joystick pad --- CMakeLists.txt | 1 + Source/JoystickPad.cpp | 287 ++++++++++++++++++++++++++++++++++++++++ Source/JoystickPad.h | 70 ++++++++++ Source/LcdDisplay.cpp | 6 +- Source/PluginEditor.cpp | 113 ++++++++-------- Source/PluginEditor.h | 17 +-- 6 files changed, 418 insertions(+), 76 deletions(-) create mode 100644 Source/JoystickPad.cpp create mode 100644 Source/JoystickPad.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 1e7972a..05379ea 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/Source/JoystickPad.cpp b/Source/JoystickPad.cpp new file mode 100644 index 0000000..e01aaad --- /dev/null +++ b/Source/JoystickPad.cpp @@ -0,0 +1,287 @@ +#include "JoystickPad.h" + +#include + +namespace { + +constexpr float kPi = juce::MathConstants::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); + } +} \ No newline at end of file diff --git a/Source/JoystickPad.h b/Source/JoystickPad.h new file mode 100644 index 0000000..374a4f0 --- /dev/null +++ b/Source/JoystickPad.h @@ -0,0 +1,70 @@ +#pragma once + +#include + +// 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 (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 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) +}; \ No newline at end of file diff --git a/Source/LcdDisplay.cpp b/Source/LcdDisplay.cpp index 3800929..98c758c 100644 --- a/Source/LcdDisplay.cpp +++ b/Source/LcdDisplay.cpp @@ -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. } \ No newline at end of file diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp index c9d2cf0..7b0b478 100644 --- a/Source/PluginEditor.cpp +++ b/Source/PluginEditor.cpp @@ -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) - { - 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); + addAndMakeVisible (navEnter); + addAndMakeVisible (navEsc); + + addAndMakeVisible (navStick); + navStick.setRepeatSpeed (250, 50); + + navStick.onAction = [this](int dir) { + switch (dir) + { + 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() diff --git a/Source/PluginEditor.h b/Source/PluginEditor.h index 190f608..e0f2f6d 100644 --- a/Source/PluginEditor.h +++ b/Source/PluginEditor.h @@ -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;