Files
serumalt/Source/GUI/Knob.cpp
T
biggy e4cea300db chore: land janitor round 1 and graft integration
Round 1 findings and fixes, all previously uncommitted:
- FXProcessor per-slot DSP history repair (HEAD does not compile without it)
- SynthVoice::getModulationValue, editor control-lock guard
- ExactCompare.h naming the deliberate exact float comparisons
- KnobLookAndFeel replacing the never-installed SerumLookAndFeel
- 55 first-party compiler warnings to 0
- AGENT.md renamed to AGENTS.md across Source/
- graft repository context graph and its hooks
- JANITOR_TASKS.md and UI_UX_AUDIT.md records
2026-10-06 08:50:45 +02:00

155 lines
5.0 KiB
C++

#include "Knob.h"
namespace serum
{
namespace
{
// One shared look-and-feel for every knob. It outlives every Knob because
// it is a function-local static and the GUI owns the knobs.
KnobLookAndFeel& knobStyle()
{
static KnobLookAndFeel style;
return style;
}
}
Knob::Knob (const juce::String& name, std::function<juce::String (float)> 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);
// Draw the dial and the name strip through the theme, not through the
// shared default look-and-feel.
setLookAndFeel (&knobStyle());
}
juce::String Knob::getTextFromValue (double value)
{
if (formatter)
return formatter ((float) value);
return formatPercent ((float) value);
}
// The dial takes the top of the footprint and the name takes a strip at the
// bottom, so the name is always visible without a tooltip hover.
void Knob::resized()
{
juce::Slider::resized();
textArea = getLocalBounds().removeFromBottom (
juce::jmin (KnobLookAndFeel::labelHeight, getHeight() / 2));
}
void Knob::paint (juce::Graphics& g)
{
juce::Slider::paint (g);
if (textArea.isEmpty())
return;
// Dim while disabled, bright while hovered, otherwise the normal label
// colour, so the three states are distinguishable without colour alone.
const auto colour = isEnabled() ? (isMouseOverOrDragging() ? theme::text : theme::textDim)
: theme::outline;
g.setColour (colour);
g.setFont (juce::Font (11.0f));
g.drawText (getName(), textArea, juce::Justification::centred, false);
}
void Knob::mouseDown (const juce::MouseEvent& e)
{
juce::Slider::mouseDown (e);
showValuePopup (e);
}
void Knob::mouseDrag (const juce::MouseEvent& e)
{
juce::Slider::mouseDrag (e);
showValuePopup (e);
}
void Knob::mouseUp (const juce::MouseEvent& e)
{
juce::Slider::mouseUp (e);
hideValuePopup();
}
void Knob::mouseExit (const juce::MouseEvent& e)
{
juce::Slider::mouseExit (e);
hideValuePopup();
}
// ---------------------------------------------------------------------------
// The floating readout lives as a child of the top-level component so it can
// follow the cursor outside this knob's own bounds without being clipped, and
// it never intercepts mouse clicks so it can't block interaction.
// ---------------------------------------------------------------------------
void Knob::showValuePopup (const juce::MouseEvent& e)
{
auto* topLevel = getTopLevelComponent();
if (topLevel == nullptr)
return;
constexpr int baseW = 84;
constexpr int baseH = 24;
constexpr int gap = 10;
// The interface scale is applied to a parent component, not to this slider.
// Measure it from the local-space distance between two neighbouring points,
// so a scaled interface does not leave the readout at a fixed size.
float scale = 1.0f;
if (auto* parent = getParentComponent())
{
const auto origin = getLocalPoint (parent, juce::Point<int> (0, 0)).toFloat();
const auto unitX = getLocalPoint (parent, juce::Point<int> (1, 0)).toFloat();
scale = origin.getDistanceFrom (unitX);
}
scale = juce::jlimit (0.5f, 2.5f, scale);
const int w = juce::roundToInt (baseW * scale);
const int h = juce::roundToInt (baseH * scale);
if (valuePopup == nullptr)
{
valuePopup = std::make_unique<juce::Label>();
valuePopup->setAlwaysOnTop (true);
valuePopup->setInterceptsMouseClicks (false, false);
valuePopup->setColour (juce::Label::backgroundColourId, juce::Colours::transparentBlack);
valuePopup->setColour (juce::Label::textColourId, theme::text);
valuePopup->setJustificationType (juce::Justification::centred);
topLevel->addAndMakeVisible (valuePopup.get());
}
valuePopup->setFont (juce::Font (14.0f * scale, juce::Font::bold));
valuePopup->setText (getTextFromValue (getValue()), juce::dontSendNotification);
// Keep the readout inside the window so a knob near an edge still shows it.
const auto popupPos = topLevel->getLocalPoint (nullptr, e.getScreenPosition());
valuePopup->setBounds (juce::jlimit (0, juce::jmax (0, topLevel->getWidth() - w),
popupPos.getX() - w / 2),
juce::jlimit (0, juce::jmax (0, topLevel->getHeight() - h),
popupPos.getY() - h - gap),
w, h);
}
void Knob::hideValuePopup()
{
valuePopup.reset();
}
// A section switch can hide the view holding this knob mid-drag. The popup is a
// sibling of that view, so it has to be taken down here or it stays on screen.
void Knob::visibilityChanged()
{
if (! isVisible())
hideValuePopup();
}
} // namespace serum