diff --git a/CMakeLists.txt b/CMakeLists.txt
index 82e3723..10d642a 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -44,5 +44,6 @@ if (SUPPRESSOR_BUILD_PLUGIN)
FetchContent_MakeAvailable(clap-juce-extensions)
endif()
+ include(libs/affine-ui/AffineUI.cmake)
add_subdirectory(Source)
endif()
diff --git a/README.md b/README.md
index 1eee6e5..ad68454 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@ body alone. Formats: **VST3** and **CLAP** (Windows, macOS, Linux).
[](https://github.com/affinedsp/suppressor/actions/workflows/ci.yml)
-
+
## What it does
@@ -37,15 +37,22 @@ biquads plus an envelope per channel).
| **Release** | Sets how quickly suppression returns after a note (2–30 ms). Increase it to preserve tails. |
| **Listen removed** | Auditions input minus processed audio; switch it off to hear the processed signal. This difference includes filter phase effects, not just noise. |
-Input and output meters show **peak dBFS**. The large readout shows
-**high-band gain reduction**, not whole-signal loudness loss; in multiband
-sessions it shows the deepest band reduction. Clip warnings, bypass, silence,
-learning, and stopped audio are labelled explicitly. Stopped meters clear
-instead of holding stale readings.
-
-Drag a dial to adjust; **Shift-drag** for fine control, **double-click / Return**
-for exact entry, and **Alt/Option-click / Home** to reset. Tab and arrow keys
-work too. Use your host's bypass to compare with the dry input.
+The screen plots **high-band gain reduction** over the last six seconds, not
+whole-signal loudness loss, with the current value above the plot; in multiband
+sessions it shows the deepest band reduction. The **INPUT** and **OUTPUT** bars
+show **peak dBFS** with a readout, each with a clip cell held for one second.
+While *Listen removed* is on, the output bar is relabelled **REMOVED**. Bypass,
+silence, learning, and stopped audio are labelled explicitly; the plot breaks
+wherever there was no reading, and stopped bars go dark instead of holding
+stale readings.
+
+Drag a knob to adjust; **Shift-drag** for fine control, **double-click / Return**
+for exact entry in its readout, and **Alt/Option-click / Home** to reset.
+Right-click for the parameter menu, including your host's items. Tab and arrow
+keys work too. Use your host's bypass to compare with the dry input.
+
+The interface follows the shared [Affine design language](libs/affine-ui/DESIGN_LANGUAGE.md);
+see [`docs/UI_DESIGN.md`](docs/UI_DESIGN.md) for the Suppressor-specific details.
Existing sessions and automation remain compatible. The additional engine
settings remain available in the host's parameter view, not in the simplified
diff --git a/Source/CMakeLists.txt b/Source/CMakeLists.txt
index a0c2352..c86e7b7 100644
--- a/Source/CMakeLists.txt
+++ b/Source/CMakeLists.txt
@@ -1,10 +1,3 @@
-juce_add_binary_data(SuppressorAssets
- NAMESPACE SuppressorAssets
- HEADER_NAME SuppressorAssets.h
- SOURCES
- ../assets/fonts/BarlowCondensed-Regular.ttf
- ../assets/fonts/BarlowCondensed-SemiBold.ttf)
-
juce_add_plugin(Suppressor
VERSION 0.1.0
COMPANY_NAME "Suppressor Audio"
@@ -25,8 +18,7 @@ juce_add_plugin(Suppressor
target_sources(Suppressor PRIVATE
PluginProcessor.cpp
- PluginEditor.cpp
- PluginLookAndFeel.cpp)
+ PluginEditor.cpp)
target_compile_definitions(Suppressor PUBLIC
JUCE_WEB_BROWSER=0
@@ -36,7 +28,7 @@ target_compile_definitions(Suppressor PUBLIC
JUCE_STRICT_REFCOUNTEDPOINTER=1)
target_link_libraries(Suppressor PRIVATE
- SuppressorAssets
+ affine_ui
suppressor-dsp
juce::juce_audio_utils
juce::juce_recommended_config_flags
@@ -64,6 +56,7 @@ if (SUPPRESSOR_BUILD_TESTS)
target_link_libraries(SuppressorUITests PRIVATE
Suppressor
+ affine_ui
doctest::doctest
juce::juce_audio_utils
juce::juce_recommended_config_flags
diff --git a/Source/PluginEditor.cpp b/Source/PluginEditor.cpp
index d4b96b4..c00c657 100644
--- a/Source/PluginEditor.cpp
+++ b/Source/PluginEditor.cpp
@@ -1,205 +1,302 @@
#include "PluginEditor.h"
-#include
namespace
{
-using namespace SuppressorTheme;
-const auto bg = canvas, panel = surface, line = divider, text = primaryText,
- secondary = secondaryText, neutral = controlOutline;
-
-void label (juce::Graphics& g, const juce::String& s, juce::Rectangle r,
- float size, juce::Colour colour, juce::Justification align = juce::Justification::left)
-{
- g.setColour (colour);
- g.setFont (SuppressorTheme::makeFont (size, size >= 20.0f));
- g.drawText (s, r, align);
-}
float db (float peak) { return juce::Decibels::gainToDecibels (peak, -100.0f); }
juce::String levelText (float peak) { return peak < 0.00001f ? "-inf" : juce::String (db (peak), 1); }
+juce::String whole (double v) { return juce::String (juce::roundToInt (v)); }
+
+// Layout, in logical pixels.
+const juce::Rectangle statusArea { 470, 28, 310, 20 };
+const juce::Rectangle hostSettingsArea { 470, 54, 310, 16 };
+const juce::Rectangle screenArea { 36, 86, 748, 238 };
+const juce::Rectangle summaryArea { 480, 330, 300, 14 };
+constexpr int knobAxis = 434;
+constexpr int knobCentres[] { 130, 296, 462 };
+const juce::Rectangle listenArea { 600, 368, 170, 130 };
+
+// Reduction reads on a square-root law, as on the family's reduction meters.
+float depthFor (float decibels) { return std::sqrt (juce::jlimit (0.0f, 1.0f, decibels / 60.0f)); }
} // namespace
-SuppressorDial::SuppressorDial (juce::AudioProcessorValueTreeState& state, const juce::String& id,
- const juce::String& help)
- : parameter (*state.getParameter (id)), attachment (state, id, *this)
+affine::Theme SuppressorTheme::theme()
{
- setComponentID (id);
- setName (parameter.getName (64));
- setTitle (getName());
- setDescription (help);
- setTooltip (help + " Double-click or Return to type. Shift-drag for fine adjustment. "
- "Alt/Option-click to reset; right-click for the menu.");
- setSliderStyle (juce::Slider::RotaryHorizontalVerticalDrag);
- setTextBoxStyle (juce::Slider::NoTextBox, false, 0, 0);
- setTextValueSuffix (" " + parameter.getLabel());
- setRotaryParameters (juce::MathConstants::pi * 1.25f,
- juce::MathConstants::pi * 2.75f, true);
- setScrollWheelEnabled (false);
- setWantsKeyboardFocus (true);
- setMouseClickGrabsKeyboardFocus (true);
- setDoubleClickReturnValue (false, 0.0);
- addChildComponent (entry);
- entry.setName (getName() + " exact value");
- entry.setJustification (juce::Justification::centred);
- entry.setFont (SuppressorTheme::makeFont (18.0f));
- entry.onReturnKey = [this] { finishEntry (true); };
- entry.onEscapeKey = [this] { finishEntry (false); };
- entry.onFocusLost = [this] { finishEntry (false); };
+ affine::Theme t;
+ t.palette.screen = juce::Colour (0xff35e0c8);
+ return t;
}
-SuppressorDial::~SuppressorDial() { cancelInteraction(); }
-void SuppressorDial::resized()
+ReductionScreen::ReductionScreen()
{
- juce::Slider::resized();
- entry.setBounds (getLocalBounds().withSizeKeepingCentre (100, 32));
+ history.fill (std::numeric_limits::quiet_NaN());
+ setInterceptsMouseClicks (false, false);
+ setAccessible (false);
}
-void SuppressorDial::setWithGesture (double v)
+
+void ReductionScreen::setTheme (const affine::Theme& newTheme)
{
- juce::Slider::ScopedDragNotification gesture (*this);
- setValue (v, juce::sendNotificationSync);
+ theme = newTheme;
+ repaint();
}
-void SuppressorDial::mouseDown (const juce::MouseEvent& e)
+
+void ReductionScreen::push (float reductionDb, bool hasReduction, float inputDb, float outputDb, bool isLive,
+ bool inputClip, bool outputClip, bool removed, bool multiband)
{
- if (! isEnabled()) return;
- grabKeyboardFocus();
- if (e.mods.isPopupMenu()) { showMenu(); return; }
- if (e.mods.isAltDown())
- {
- setWithGesture (parameter.convertFrom0to1 (parameter.getDefaultValue()));
+ const auto wasIdle = valid == 0 && ! live;
+ auto& slot = history[static_cast (head)];
+ if (! std::isnan (slot))
+ --valid;
+ slot = hasReduction ? juce::jmax (0.0f, reductionDb) : std::numeric_limits::quiet_NaN();
+ if (hasReduction)
+ ++valid;
+ head = (head + 1) % frames;
+
+ const auto captionsChanged = removed != removedSignal || multiband != multi;
+ latest = reductionDb;
+ current = hasReduction;
+ live = isLive;
+ input = inputDb;
+ output = outputDb;
+ inputClipped = inputClip;
+ outputClipped = outputClip;
+ removedSignal = removed;
+ multi = multiband;
+ if (wasIdle && valid == 0 && ! live && ! captionsChanged)
return;
- }
- lastDrag = e.position;
- dragProportion = valueToProportionOfLength (getValue());
- drag = std::make_unique (*this);
-}
-void SuppressorDial::mouseDrag (const juce::MouseEvent& e)
-{
- if (drag == nullptr) return;
- const auto delta = e.position - lastDrag;
- const double sensitivity = e.mods.isShiftDown() ? 2400.0 : 240.0;
- // Accumulate sub-step movement so fine drags work on the 0.1 dB grid.
- dragProportion = juce::jlimit (0.0, 1.0, dragProportion + (delta.x - delta.y) / sensitivity);
- setValue (proportionOfLengthToValue (dragProportion), juce::sendNotificationSync);
- lastDrag = e.position;
-}
-void SuppressorDial::mouseUp (const juce::MouseEvent&) { drag.reset(); }
-void SuppressorDial::mouseDoubleClick (const juce::MouseEvent& e)
-{
- if (! e.mods.isAltDown() && ! e.mods.isPopupMenu()) beginEntry();
-}
-void SuppressorDial::cancelInteraction()
-{
- drag.reset();
- finishEntry (false);
+ repaint();
}
-void SuppressorDial::enablementChanged()
+
+void ReductionScreen::paint (juce::Graphics& g)
{
- if (! isEnabled()) cancelInteraction();
- repaint();
+ const auto glass = getLocalBounds().toFloat().reduced (5.0f);
+ affine::render::screenGlass (g, glass);
+
+ auto inner = glass.reduced (18.0f, 14.0f);
+ auto levels = inner.removeFromRight (184.0f);
+ inner.removeFromRight (24.0f);
+ g.setColour (theme.palette.screen.withAlpha (0.10f));
+ g.fillRect (levels.getX() - 12.5f, inner.getY() + 4.0f, 1.0f, inner.getHeight() - 8.0f);
+ paintHistory (g, inner);
+
+ levels = levels.withSizeKeepingCentre (levels.getWidth(), 160.0f);
+ paintLevel (g, levels.removeFromTop (62.0f), "INPUT", input, inputClipped, theme.palette.screen);
+ paintLevel (g, levels.removeFromBottom (62.0f), removedSignal ? "REMOVED" : "OUTPUT", output, outputClipped,
+ removedSignal ? theme.palette.attention : theme.palette.screen);
}
-bool SuppressorDial::keyPressed (const juce::KeyPress& key)
+
+void ReductionScreen::paintHistory (juce::Graphics& g, juce::Rectangle area)
{
- if (! isEnabled()) return false;
- if (key == juce::KeyPress::returnKey) { beginEntry(); return true; }
- const int code = key.getKeyCode();
- if (code == juce::KeyPress::upKey || code == juce::KeyPress::rightKey
- || code == juce::KeyPress::downKey || code == juce::KeyPress::leftKey)
+ const auto screenColour = theme.palette.screen;
+ const auto dim = screenColour.withAlpha (0.45f);
+ auto header = area.removeFromTop (36.0f);
+ affine::screen::caption (g, multi ? "MAX BAND REDUCTION" : "HIGH-BAND REDUCTION", header.withHeight (15.0f), screenColour);
+ g.setColour (dim);
+ g.setFont (affine::fonts::label (10.0f, 0.2f));
+ g.drawText ("dB, LAST SIX SECONDS", header.withTrimmedTop (18.0f).withHeight (12.0f), juce::Justification::centredLeft, false);
+
+ auto unit = header.removeFromRight (24.0f);
+ g.setColour (current ? screenColour : dim);
+ g.setFont (affine::fonts::label (12.0f, 0.1f));
+ g.drawText ("dB", unit.withTrimmedBottom (3.0f), juce::Justification::bottomRight, false);
+ const auto reading = ! current ? juce::String ("--") : latest >= 60.0f ? juce::String ("60+") : juce::String (latest, 1);
+ readoutGlow.draw (g, reading, affine::fonts::readout (32.0f), header.withTrimmedRight (4.0f), juce::Justification::centredRight,
+ current ? affine::screen::lit (screenColour) : screenColour.withAlpha (0.35f), 4.0f,
+ current ? 0.9f : 0.3f);
+
+ area.removeFromTop (10.0f);
+ const auto timeAxis = area.removeFromBottom (16.0f);
+ const auto gutter = area.removeFromLeft (26.0f);
+ const auto plot = area;
+ const auto yFor = [&] (float decibels) { return plot.getY() + plot.getHeight() * depthFor (decibels); };
+ const auto xFor = [&] (int frame) { return plot.getX() + plot.getWidth() * static_cast (frame) / static_cast (frames - 1); };
+
+ g.setFont (affine::fonts::label (10.0f, 0.02f));
+ for (float mark : { 0.0f, 5.0f, 10.0f, 20.0f, 40.0f, 60.0f })
{
- const double step = getComponentID() == "strength" ? 0.01 : 0.1;
- const int direction = code == juce::KeyPress::upKey || code == juce::KeyPress::rightKey ? 1 : -1;
- const auto fine = key.getModifiers().isShiftDown() && getInterval() <= 0.0 ? 0.1 : 1.0;
- setWithGesture (getValue() + direction * step * fine);
- return true;
+ const auto y = yFor (mark);
+ g.setColour (screenColour.withAlpha (mark == 0.0f ? 0.22f : 0.08f));
+ g.fillRect (plot.getX(), y - 0.5f, plot.getWidth(), 1.0f);
+ g.setColour (dim);
+ g.drawText (whole (mark), juce::Rectangle (gutter.getWidth() - 7.0f, 12.0f).withPosition (gutter.getX(), y - 6.0f),
+ juce::Justification::centredRight, false);
}
- if (code == juce::KeyPress::homeKey)
+ g.setColour (screenColour.withAlpha (0.07f));
+ for (int second = 1; second < 6; ++second)
{
- setWithGesture (parameter.convertFrom0to1 (parameter.getDefaultValue()));
- return true;
+ const auto x = plot.getX() + plot.getWidth() * static_cast (second) / 6.0f;
+ for (auto y = plot.getY() + 2.0f; y < plot.getBottom(); y += 4.0f)
+ g.fillRect (x - 0.5f, y, 1.0f, 1.5f);
}
- return juce::Slider::keyPressed (key);
-}
-void SuppressorDial::beginEntry()
-{
- if (! isEnabled()) return;
- drag.reset();
- entry.setText (getTextFromValue (getValue()), false);
- entry.setVisible (true);
- entry.grabKeyboardFocus();
- entry.selectAll();
-}
-void SuppressorDial::finishEntry (bool commit)
-{
- if (! entry.isVisible()) return;
- const auto typed = entry.getText().trim();
- entry.setVisible (false);
- if (commit)
+ g.setColour (dim);
+ g.drawText ("-6 S", timeAxis.withWidth (40.0f).withX (plot.getX()), juce::Justification::centredLeft, false);
+ g.drawText ("-3 S", juce::Rectangle (40.0f, timeAxis.getHeight()).withCentre ({ plot.getCentreX(), timeAxis.getCentreY() }),
+ juce::Justification::centred, false);
+ g.drawText ("NOW", timeAxis.withLeft (plot.getRight() - 40.0f).withRight (plot.getRight()), juce::Justification::centredRight, false);
+
+ // One filled shape per unbroken run of readings; gaps are frames without a reading.
+ juce::Path fill, edge;
+ bool inRun = false;
+ juce::Point last;
+ for (int i = 0; i <= frames; ++i)
{
- // Accept one finite number plus an optional matching unit. Do not let
- // the parameter's permissive parser turn malformed input into zero.
- auto number = typed;
- const auto unit = parameter.getLabel();
- if (number.endsWithIgnoreCase (unit)) number = number.dropLastCharacters (unit.length()).trimEnd();
- const auto utf8 = number.toRawUTF8();
- char* end = nullptr;
- const auto value = std::strtod (utf8, &end);
- if (number.containsOnly ("0123456789.+-") && end != utf8 && *end == '\0' && std::isfinite (value))
+ const auto value = i < frames ? history[static_cast ((head + i) % frames)] : std::numeric_limits::quiet_NaN();
+ if (! std::isnan (value))
{
- const double plain = getComponentID() == "strength" ? value / 100.0 : value;
- setWithGesture (plain);
+ const juce::Point at { xFor (i), yFor (value) };
+ if (! inRun)
+ {
+ fill.startNewSubPath (at.x, plot.getY());
+ edge.startNewSubPath (at);
+ inRun = true;
+ }
+ else
+ {
+ edge.lineTo (at);
+ }
+ fill.lineTo (at);
+ last = at;
+ }
+ else if (inRun)
+ {
+ fill.lineTo (last.x, plot.getY());
+ fill.closeSubPath();
+ inRun = false;
}
- grabKeyboardFocus();
}
- repaint();
+ g.setGradientFill (juce::ColourGradient (screenColour.withAlpha (0.05f), 0.0f, plot.getY(),
+ screenColour.withAlpha (0.42f), 0.0f, plot.getBottom(), false));
+ g.fillPath (fill);
+ const juce::PathStrokeType::JointStyle joint = juce::PathStrokeType::curved;
+ g.setColour (screenColour.withAlpha (0.16f));
+ g.strokePath (edge, juce::PathStrokeType (5.0f, joint, juce::PathStrokeType::rounded));
+ g.setColour (screenColour);
+ g.strokePath (edge, juce::PathStrokeType (1.6f, joint, juce::PathStrokeType::rounded));
+
+ if (current)
+ {
+ const juce::Point now { xFor (frames - 1), yFor (latest) };
+ affine::render::halo (g, now, 10.0f, screenColour, 0.7f);
+ g.setColour (screenColour.interpolatedWith (juce::Colours::white, 0.5f));
+ g.fillEllipse (juce::Rectangle (5.0f, 5.0f).withCentre (now));
+ }
+ else if (valid == 0)
+ {
+ g.setColour (screenColour.withAlpha (0.4f));
+ g.setFont (affine::fonts::label (13.0f, 0.3f));
+ g.drawText (live ? "NO READING" : "NO AUDIO", plot, juce::Justification::centred, false);
+ }
}
-void SuppressorDial::showMenu()
+
+void ReductionScreen::paintLevel (juce::Graphics& g, juce::Rectangle area, const juce::String& name, float level,
+ bool clipped, juce::Colour colour)
{
- juce::PopupMenu menu;
- const juce::Component::SafePointer safe (this);
- menu.addItem ("Enter value...", [safe] { if (safe != nullptr) safe->beginEntry(); });
- menu.addItem ("Reset to default", [safe]
+ const auto& palette = theme.palette;
+ auto top = area.removeFromTop (22.0f);
+ affine::screen::caption (g, name, top, colour);
+ g.setColour (live ? affine::screen::lit (colour) : colour.withAlpha (0.35f));
+ g.setFont (affine::fonts::readout (19.0f));
+ g.drawText (! live ? juce::String ("--") : level <= -99.0f ? juce::String ("-inf") : juce::String (level, 1), top,
+ juce::Justification::centredRight, false);
+
+ area.removeFromTop (8.0f);
+ auto bar = area.removeFromTop (10.0f);
+ const auto clip = bar.removeFromRight (16.0f);
+ bar.removeFromRight (6.0f);
+ affine::screen::segments (g, bar, 40, live ? (level + 60.0f) / 60.0f : 0.0f, [&] (float position, bool lit)
{
- if (safe != nullptr)
- safe->setWithGesture (safe->parameter.convertFrom0to1 (safe->parameter.getDefaultValue()));
+ const auto decibels = -60.0f + 60.0f * position;
+ const auto zone = decibels > -3.0f ? palette.attention
+ : decibels > -12.0f ? colour.interpolatedWith (juce::Colours::white, 0.45f) : colour;
+ return lit ? zone : zone.withAlpha (0.1f);
});
- if (auto* editor = findParentComponentOfClass())
- if (auto* host = editor->getHostContext())
- if (auto hostMenu = host->getContextMenuForParameter (¶meter))
- menu.addSubMenu ("Host", hostMenu->getEquivalentPopupMenu());
- menu.showMenuAsync (juce::PopupMenu::Options().withTargetComponent (this));
+ if (clipped)
+ affine::render::halo (g, clip.getCentre(), 14.0f, palette.danger, 0.6f);
+ g.setColour (clipped ? palette.danger : palette.danger.withAlpha (0.16f));
+ g.fillRect (clip);
+
+ area.removeFromTop (4.0f);
+ g.setFont (affine::fonts::label (9.5f, 0.02f));
+ for (int value : { -48, -36, -24, -12, -6, 0 })
+ {
+ const auto x = bar.getX() + bar.getWidth() * static_cast (value + 60) / 60.0f;
+ g.setColour (colour.withAlpha (0.3f));
+ g.fillRect (x - 0.5f, area.getY(), 1.0f, 3.0f);
+ g.setColour (colour.withAlpha (0.5f));
+ g.drawText (juce::String (value), juce::Rectangle (24.0f, 11.0f).withCentre ({ x, area.getY() + 10.0f }),
+ juce::Justification::centred, false);
+ }
+ g.setColour (palette.danger.withAlpha (clipped ? 1.0f : 0.6f));
+ g.drawText ("CLIP", juce::Rectangle (30.0f, 11.0f).withCentre ({ clip.getCentreX(), area.getY() + 10.0f }),
+ juce::Justification::centred, false);
}
SuppressorEditor::SuppressorEditor (SuppressorProcessor& p)
- : juce::AudioProcessorEditor (&p), proc (p),
+ : juce::AudioProcessorEditor (&p), proc (p), theme (SuppressorTheme::theme()), look (theme),
threshold (p.apvts, "threshold", "Highs below this level are reduced. Raise it until noise between notes is suppressed."),
strength (p.apvts, "strength", "Higher strength suppresses a wider high-frequency range. Zero is not bypass."),
release (p.apvts, "release", "How quickly suppression returns after a note. Increase to preserve longer tails."),
listenAttachment (p.apvts, "deltaAudition", listen)
{
- setLookAndFeel (&theme);
+ setLookAndFeel (&look);
+ setOpaque (true);
+
int order = 1;
for (auto* dial : { &threshold, &strength, &release })
{
- addAndMakeVisible (*dial);
+ dial->setTheme (theme);
+ dial->setDiameter (affine::metrics::knobMedium);
dial->setExplicitFocusOrder (order++);
+ addAndMakeVisible (*dial);
}
+ threshold.setScale ({ -80, -60, -40, -20, 0 }, whole);
+ strength.setScale ({ 0.0, 0.25, 0.5, 0.75, 1.0 }, [] (double v) { return whole (v * 100.0); });
+ release.setScale ({ 2, 4, 8, 15, 30 }, whole);
+ strength.setKeyboardStep (0.01);
+ release.setKeyboardStep (0.1);
+
listen.setClickingTogglesState (true);
listen.setComponentID ("deltaAudition");
listen.setTitle ("Listen removed");
listen.setTooltip ("Audition input minus processed audio, including filter phase differences; not isolated noise. Turn off to hear the processed signal.");
listen.setExplicitFocusOrder (4);
+ listen.setTheme (theme);
+ listen.setKeySize (76.0f, 56.0f);
+ listen.setLampColour (theme.palette.attention);
+ listen.setLegend ("Listen", "Removed");
+ juce::Path headphones;
+ headphones.addCentredArc (0.5f, 0.62f, 0.4f, 0.52f, 0.0f, -juce::MathConstants::halfPi,
+ juce::MathConstants::halfPi, true);
+ headphones.addRoundedRectangle (0.02f, 0.55f, 0.22f, 0.43f, 0.06f);
+ headphones.addRoundedRectangle (0.76f, 0.55f, 0.22f, 0.43f, 0.06f);
+ listen.setGlyph (headphones);
addAndMakeVisible (listen);
- for (auto* l : { &status, &meterSummary, &hostSettings })
- {
- addAndMakeVisible (*l);
- l->setFont (SuppressorTheme::makeFont (16.0f));
- l->setBorderSize (juce::BorderSize (0));
- }
- status.setJustificationType (juce::Justification::centredRight);
+
+ screen.setTheme (theme);
+ addAndMakeVisible (screen);
+
status.setName ("Processing status");
+ status.setTheme (theme);
+ status.setGlassVisible (false);
+ status.setJustificationType (juce::Justification::centredRight);
+ status.setDisplayFont (affine::fonts::label (14.0f, 0.24f));
+ hostSettings.setName ("Host settings");
+ hostSettings.setTheme (theme);
+ hostSettings.setGlassVisible (false);
+ hostSettings.setJustificationType (juce::Justification::centredRight);
+ hostSettings.setDisplayFont (affine::fonts::label (11.5f, 0.2f));
+ hostSettings.setEmission (theme.palette.silkscreenDim, 1.0f);
+ hostSettings.setLampColour (theme.palette.attention);
meterSummary.setName ("Signal meters");
meterSummary.setTooltip ("Input and output: peak across channels, dBFS. Reduction: deepest gate attenuation, not overall loudness loss. 300 ms peak decay; clips held for one second.");
- hostSettings.setJustificationType (juce::Justification::centredRight);
- hostSettings.setName ("Host settings");
- setSize (640, 460);
+ meterSummary.setFont (affine::fonts::label (11.0f, 0.22f));
+ meterSummary.setColour (juce::Label::textColourId, theme.palette.silkscreenDim);
+ meterSummary.setJustificationType (juce::Justification::centredRight);
+ meterSummary.setBorderSize (juce::BorderSize (0));
+ for (juce::Component* c : { static_cast (&status), static_cast (&meterSummary),
+ static_cast (&hostSettings) })
+ addAndMakeVisible (c);
+
+ setSize (width, height);
// A reopened editor must see a NEW audio block before calling data live.
proc.readMeters (meter);
lastSequence = meter.sequence;
@@ -208,34 +305,42 @@ SuppressorEditor::SuppressorEditor (SuppressorProcessor& p)
timerCallback();
startTimerHz (30);
}
+
SuppressorEditor::~SuppressorEditor()
{
stopTimer();
setLookAndFeel (nullptr);
}
+
void SuppressorEditor::resized()
{
- status.setBounds (365, 24, 251, 28);
- meterSummary.setBounds (36, 224, 355, 18);
- threshold.setBounds (59, 277, 122, 116);
- strength.setBounds (259, 277, 122, 116);
- release.setBounds (459, 277, 122, 116);
- listen.setBounds (24, 412, 160, 32);
- hostSettings.setBounds (464, 419, 152, 20);
+ status.setBounds (statusArea);
+ hostSettings.setBounds (hostSettingsArea);
+ screen.setBounds (screenArea);
+ meterSummary.setBounds (summaryArea);
+ int i = 0;
+ for (auto* dial : { &threshold, &strength, &release })
+ dial->setBounds (dial->getBoundsForCentre ({ knobCentres[i++], knobAxis }));
+ listen.setBounds (listenArea);
}
+
void SuppressorEditor::visibilityChanged()
{
if (! isShowing())
- for (auto* dial : { &threshold, &strength, &release }) dial->cancelInteraction();
+ for (auto* dial : { &threshold, &strength, &release })
+ dial->cancelInteraction();
}
+
int SuppressorEditor::getControlParameterIndex (juce::Component& c)
{
for (auto* dial : { &threshold, &strength, &release })
- if (&c == dial || dial->isParentOf (&c)) return dial->parameter.getParameterIndex();
+ if (&c == dial || dial->isParentOf (&c))
+ return dial->parameter.getParameterIndex();
if (&c == &listen || listen.isParentOf (&c))
return proc.apvts.getParameter ("deltaAudition")->getParameterIndex();
return -1;
}
+
void SuppressorEditor::timerCallback()
{
const auto now = juce::Time::getMillisecondCounterHiRes();
@@ -254,19 +359,25 @@ void SuppressorEditor::timerCallback()
&& (((meter.flags & suppressor::MeterSnapshot::removed) != 0) == listen.getToggleState())
&& (((meter.flags & suppressor::MeterSnapshot::multiband) != 0)
== (proc.apvts.getRawParameterValue ("bandMode")->load() > 0.5f));
- if (! isShowing()) return;
+ if (! isShowing())
+ return;
using M = suppressor::MeterSnapshot;
+ const auto& palette = theme.palette;
const bool bypass = fresh && (meter.flags & M::bypassed) != 0;
const bool learning = fresh && (meter.flags & M::learning) != 0;
const bool audition = listen.getToggleState() && ! bypass;
+ const bool multi = proc.apvts.getRawParameterValue ("bandMode")->load() > 0.5f;
listen.setButtonText (listen.getToggleState() ? "Stop listening" : "Listen removed");
- juce::String state = ! fresh ? "No audio" : bypass ? "Bypassed" : learning ? "Learning bands"
- : meter.input < 0.00001f ? "No input" : audition ? "Listening to difference"
- : meter.reduction > 0.5f ? "Suppressing" : "Passing signal";
+
+ const juce::String state = ! fresh ? "No audio" : bypass ? "Bypassed" : learning ? "Learning bands"
+ : meter.input < 0.00001f ? "No input" : audition ? "Listening to difference"
+ : meter.reduction > 0.5f ? "Suppressing" : "Passing signal";
status.setText (state, juce::dontSendNotification);
- status.setColour (juce::Label::textColourId, audition ? warning : text);
- const bool multi = proc.apvts.getRawParameterValue ("bandMode")->load() > 0.5f;
+ status.setEmission (fresh ? palette.silkscreen : palette.silkscreenDim, fresh ? 1.0f : 0.0f);
+ status.setLampColour (bypass ? palette.danger : (audition || learning) ? palette.attention
+ : state == "Suppressing" ? palette.screen : palette.accent);
+
threshold.setEnabled (! multi);
strength.setEnabled (! multi);
@@ -280,6 +391,7 @@ void SuppressorEditor::timerCallback()
hostSettings.setText (changed.isEmpty() ? "" : "Host settings active", juce::dontSendNotification);
hostSettings.setTooltip ("Legacy settings are preserved. Edit them in your host's parameter view.\n" + changed.joinIntoString ("\n"));
hostSettings.setDescription (hostSettings.getTooltip());
+
meterSummary.setText ("PEAK LEVEL / dBFS", juce::dontSendNotification);
meterSummary.setDescription (! fresh ? "No current audio readings" :
"Input " + levelText (meter.input) + " dBFS, " + (audition ? "removed " : "output ")
@@ -294,56 +406,24 @@ void SuppressorEditor::timerCallback()
if ((meter.flags & M::outputClip) != 0) description += " Output clipped.";
meterSummary.setDescription (description);
}
- repaint();
+
+ const bool hasReduction = fresh && ! learning && (bypass || meter.input >= 0.00001f);
+ screen.push (meter.reduction, hasReduction, db (meter.input), db (meter.output), fresh,
+ fresh && (meter.flags & M::inputClip) != 0, fresh && (meter.flags & M::outputClip) != 0, audition, multi);
}
-void SuppressorEditor::drawLevel (juce::Graphics& g, int y, const juce::String& name, float peak, bool clip)
+
+void SuppressorEditor::print (juce::Graphics& g)
{
- label (g, name, { 36, y, 66, 20 }, 16.0f, secondary);
- const auto track = juce::Rectangle (106.0f, (float) y + 6.0f, 205.0f, 8.0f);
- g.setColour (line); g.fillRoundedRectangle (track, 2.0f);
- if (fresh)
- {
- g.setColour (name == "Input" ? secondary : accent);
- if (name == "Removed") g.setColour (warning);
- g.fillRoundedRectangle (track.withWidth (205.0f * juce::jlimit (0.0f, 1.0f, (db (peak) + 60.0f) / 60.0f)), 2.0f);
- }
- label (g, fresh ? levelText (peak) : "--", { 322, y - 1, 66, 22 }, 19.0f, text, juce::Justification::right);
- if (fresh && clip) label (g, "CLIP", { 319, y + 19, 69, 15 }, 14.0f, error, juce::Justification::right);
+ const auto& palette = theme.palette;
+ affine::silkscreen::wordmark (g, "Suppressor", "Guitar DI noise suppressor", { 38.0f, 20.0f }, palette);
+
+ // A hairline separates the Listen key from the three dials.
+ g.setColour (juce::Colours::white.withAlpha (0.07f));
+ g.fillRect (570.0f, 380.0f, 1.0f, 112.0f);
+ affine::silkscreen::makersMark (g, { 40.0f, static_cast (height) - 16.0f }, palette);
}
+
void SuppressorEditor::paint (juce::Graphics& g)
{
- using M = suppressor::MeterSnapshot;
- const bool bypass = fresh && (meter.flags & M::bypassed) != 0;
- const bool multi = proc.apvts.getRawParameterValue ("bandMode")->load() > 0.5f;
- const bool learning = fresh && (meter.flags & M::learning) != 0;
- const bool audition = listen.getToggleState() && ! bypass;
- const bool hasReduction = fresh && ! learning && (bypass || meter.input >= 0.00001f);
- g.fillAll (bg);
- label (g, "suppressor", { 24, 19, 270, 32 }, 30.0f, text);
- label (g, "GUITAR DI / NOISE SUPPRESSOR", { 25, 54, 340, 16 }, 14.0f, secondary);
- g.setColour (panel); g.fillRoundedRectangle (24.0f, 86.0f, 592.0f, 162.0f, 8.0f);
- drawLevel (g, 112, "Input", meter.input, (meter.flags & M::inputClip) != 0);
- drawLevel (g, 164, audition ? "Removed" : "Output", meter.output, (meter.flags & M::outputClip) != 0);
- for (int value : { -60, -36, -12, 0 })
- {
- const int x = 106 + (value + 60) * 205 / 60;
- g.setColour (neutral); g.drawVerticalLine (x, 195.0f, 199.0f);
- label (g, juce::String (value), { x - 14, 202, 28, 16 }, 14.0f, secondary, juce::Justification::centred);
- }
- g.setColour (line); g.drawVerticalLine (406, 106.0f, 230.0f);
- label (g, multi ? "MAX BAND REDUCTION" : "HIGH-BAND REDUCTION", { 423, 108, 178, 18 }, 14.0f, secondary);
- label (g, hasReduction ? (meter.reduction >= 60.0f ? "60+" : juce::String (meter.reduction, 1)) : "--", { 422, 134, 131, 56 }, 52.0f,
- hasReduction && meter.reduction > 0.5f ? accent : text);
- label (g, "dB", { 548, 158, 40, 24 }, 20.0f, secondary);
- const float amount = hasReduction ? juce::jlimit (0.0f, 1.0f, meter.reduction / 60.0f) : 0.0f;
- g.setColour (line); g.fillRect (424.0f, 202.0f, 172.0f, 6.0f);
- g.setColour (accent); g.fillRect (424.0f, 202.0f, 172.0f * amount, 6.0f);
- for (int value : { 0, 20, 40, 60 })
- label (g, juce::String (value), { 416 + value * 172 / 60, 215, 22, 17 }, 14.0f, secondary, juce::Justification::centred);
-
- label (g, "Threshold", { 24, 259, 192, 22 }, 18.0f, text, juce::Justification::centred);
- label (g, "Strength", { 224, 259, 192, 22 }, 18.0f, text, juce::Justification::centred);
- label (g, "Release", { 424, 259, 192, 22 }, 18.0f, text, juce::Justification::centred);
- label (g, bypass ? "Monitor: dry input" : audition ? "Monitor: input minus processed" : "Monitor: processed signal",
- { 196, 419, 265, 20 }, 15.0f, audition ? warning : secondary);
+ faceplate.paint (g, getLocalBounds(), theme, [this] (juce::Graphics& plate) { print (plate); });
}
diff --git a/Source/PluginEditor.h b/Source/PluginEditor.h
index 9fed4bb..f4fd2c3 100644
--- a/Source/PluginEditor.h
+++ b/Source/PluginEditor.h
@@ -1,51 +1,48 @@
#pragma once
#include "PluginProcessor.h"
-#include "PluginLookAndFeel.h"
+#include
-class SuppressorDial final : public juce::Slider
+namespace SuppressorTheme
{
-public:
- SuppressorDial (juce::AudioProcessorValueTreeState&, const juce::String& id,
- const juce::String& help);
- ~SuppressorDial() override;
- void resized() override;
- void mouseDown (const juce::MouseEvent&) override;
- void mouseDrag (const juce::MouseEvent&) override;
- void mouseUp (const juce::MouseEvent&) override;
- void mouseDoubleClick (const juce::MouseEvent&) override;
- bool keyPressed (const juce::KeyPress&) override;
- void focusGained (FocusChangeType) override { repaint(); }
- void focusLost (FocusChangeType) override { repaint(); }
- void enablementChanged() override;
- void beginEntry();
- void cancelInteraction();
- juce::RangedAudioParameter& parameter;
+/** The family theme with Suppressor's teal screen. */
+affine::Theme theme();
+}
-private:
- void finishEntry (bool commit);
- void setWithGesture (double);
- void showMenu();
- juce::TextEditor entry;
- juce::Point lastDrag;
- double dragProportion = 0.0;
- std::unique_ptr drag;
- juce::AudioProcessorValueTreeState::SliderAttachment attachment;
+class ListenButton final : public affine::KeyButton
+{
+public:
+ ListenButton() : affine::KeyButton ("Listen removed") {}
};
-class ListenButton final : public juce::TextButton
+/**
+ The screen: gain reduction as a scrolling history, with input and output
+ peak bars. Only real snapshots are plotted; stretches without audio stay empty.
+*/
+class ReductionScreen final : public juce::Component
{
public:
- ListenButton() : juce::TextButton ("Listen removed") {}
- bool keyPressed (const juce::KeyPress& key) override
- {
- if (isEnabled() && key.isKeyCode (' '))
- {
- setToggleState (! getToggleState(), juce::sendNotificationSync);
- return true;
- }
- return juce::TextButton::keyPressed (key);
- }
+ ReductionScreen();
+
+ void setTheme (const affine::Theme&);
+ /** Appends one frame. `reductionDb` is ignored when `hasReduction` is false. */
+ void push (float reductionDb, bool hasReduction, float inputDb, float outputDb, bool live, bool inputClip,
+ bool outputClip, bool removed, bool multiband);
+ void paint (juce::Graphics&) override;
+
+private:
+ static constexpr int frames = 180; // six seconds at 30 Hz
+
+ void paintHistory (juce::Graphics&, juce::Rectangle);
+ void paintLevel (juce::Graphics&, juce::Rectangle, const juce::String& name, float level, bool clipped,
+ juce::Colour);
+
+ affine::Theme theme;
+ affine::render::GlowText readoutGlow;
+ std::array history {};
+ int head = 0, valid = 0;
+ float input = -100.0f, output = -100.0f, latest = 0.0f;
+ bool live = false, current = false, inputClipped = false, outputClipped = false, removedSignal = false, multi = false;
};
class SuppressorEditor final : public juce::AudioProcessorEditor, private juce::Timer
@@ -58,15 +55,21 @@ class SuppressorEditor final : public juce::AudioProcessorEditor, private juce::
void visibilityChanged() override;
int getControlParameterIndex (juce::Component&) override;
+ static constexpr int width = 820, height = 540;
+
private:
void timerCallback() override;
- void drawLevel (juce::Graphics&, int y, const juce::String&, float peak, bool clip);
+ void print (juce::Graphics&);
SuppressorProcessor& proc;
- SuppressorLookAndFeel theme;
- SuppressorDial threshold, strength, release;
+ affine::Theme theme;
+ affine::LookAndFeel look;
+ affine::Faceplate faceplate;
+ affine::Knob threshold, strength, release;
ListenButton listen;
juce::AudioProcessorValueTreeState::ButtonAttachment listenAttachment;
- juce::Label status, meterSummary, hostSettings;
+ ReductionScreen screen;
+ affine::DisplayLabel status, hostSettings;
+ juce::Label meterSummary;
juce::TooltipWindow tooltips { this, 700 };
suppressor::MeterSnapshot meter;
uint32_t lastSequence = 0;
diff --git a/Source/PluginLookAndFeel.cpp b/Source/PluginLookAndFeel.cpp
deleted file mode 100644
index c529105..0000000
--- a/Source/PluginLookAndFeel.cpp
+++ /dev/null
@@ -1,85 +0,0 @@
-#include "PluginLookAndFeel.h"
-#include "SuppressorAssets.h"
-
-juce::Font SuppressorTheme::makeFont (float size, bool semibold)
-{
- static auto regular = juce::Typeface::createSystemTypefaceFor (
- SuppressorAssets::BarlowCondensedRegular_ttf, SuppressorAssets::BarlowCondensedRegular_ttfSize);
- static auto bold = juce::Typeface::createSystemTypefaceFor (
- SuppressorAssets::BarlowCondensedSemiBold_ttf, SuppressorAssets::BarlowCondensedSemiBold_ttfSize);
- return juce::Font (juce::FontOptions (semibold ? bold : regular).withHeight (size));
-}
-
-SuppressorLookAndFeel::SuppressorLookAndFeel()
-{
- using namespace SuppressorTheme;
- setColour (juce::Label::textColourId, secondaryText);
- setColour (juce::TextEditor::backgroundColourId, surfaceRaised);
- setColour (juce::TextEditor::textColourId, primaryText);
- setColour (juce::TextEditor::outlineColourId, controlOutline);
- setColour (juce::TextEditor::focusedOutlineColourId, accent);
- setColour (juce::TextEditor::highlightColourId, accent.withAlpha (0.3f));
- setColour (juce::CaretComponent::caretColourId, primaryText);
- setColour (juce::TextButton::textColourOffId, primaryText);
- setColour (juce::TextButton::textColourOnId, warning);
- setColour (juce::PopupMenu::backgroundColourId, surface);
- setColour (juce::PopupMenu::textColourId, primaryText);
- setColour (juce::PopupMenu::highlightedBackgroundColourId, surfaceRaised);
- setColour (juce::PopupMenu::highlightedTextColourId, accent);
- setColour (juce::TooltipWindow::backgroundColourId, surfaceRaised);
- setColour (juce::TooltipWindow::textColourId, primaryText);
- setColour (juce::TooltipWindow::outlineColourId, controlOutline);
-}
-
-void SuppressorLookAndFeel::drawRotarySlider (juce::Graphics& g, int x, int y, int w, int h,
- float position, float start, float end, juce::Slider& s)
-{
- using namespace SuppressorTheme;
- const auto bounds = juce::Rectangle (x, y, w, h).toFloat().reduced (8.0f);
- const auto centre = bounds.getCentre();
- const auto radius = juce::jmin (bounds.getWidth(), bounds.getHeight()) * 0.5f - 4.0f;
- g.setColour (surfaceRaised);
- g.fillEllipse (bounds.reduced (7.0f));
- juce::Path track, value;
- track.addCentredArc (centre.x, centre.y, radius, radius, 0.0f, start, end, true);
- value.addCentredArc (centre.x, centre.y, radius, radius, 0.0f, start,
- start + position * (end - start), true);
- g.setColour (controlOutline);
- g.strokePath (track, juce::PathStrokeType (3.0f));
- g.setColour (s.isEnabled() ? accent : controlOutline);
- g.strokePath (value, juce::PathStrokeType (3.0f));
- const auto angle = start + position * (end - start);
- const auto point = centre + juce::Point (std::sin (angle), -std::cos (angle)) * radius;
- g.fillEllipse (point.x - 3.0f, point.y - 3.0f, 6.0f, 6.0f);
- g.setColour (s.isEnabled() ? primaryText : secondaryText);
- g.setFont (makeFont (21.0f, true));
- g.drawText (s.getTextFromValue (s.getValue()), bounds, juce::Justification::centred);
- if (s.hasKeyboardFocus (true))
- {
- g.setColour (accent);
- g.drawRoundedRectangle (s.getLocalBounds().toFloat().reduced (1.0f), 6.0f, 1.5f);
- }
- else if (s.isMouseOver() && s.isEnabled())
- {
- g.setColour (controlOutline);
- g.drawRoundedRectangle (s.getLocalBounds().toFloat().reduced (1.0f), 6.0f, 1.0f);
- }
-}
-
-void SuppressorLookAndFeel::drawButtonBackground (juce::Graphics& g, juce::Button& b,
- const juce::Colour&, bool hover, bool down)
-{
- using namespace SuppressorTheme;
- const auto r = b.getLocalBounds().toFloat().reduced (1.0f);
- g.setColour (down ? divider : surfaceRaised);
- g.fillRoundedRectangle (r, 5.0f);
- g.setColour (b.getToggleState() ? warning : (hover ? primaryText : controlOutline));
- g.drawRoundedRectangle (r, 5.0f, b.hasKeyboardFocus (true) ? 2.0f : 1.0f);
- if (b.hasKeyboardFocus (true)) g.drawRoundedRectangle (r.reduced (4.0f), 3.0f, 1.0f);
-}
-
-juce::Font SuppressorLookAndFeel::getTextButtonFont (juce::TextButton&, int)
-{
- return SuppressorTheme::makeFont (17.0f, true);
-}
-juce::Font SuppressorLookAndFeel::getPopupMenuFont() { return SuppressorTheme::makeFont (17.0f); }
diff --git a/Source/PluginLookAndFeel.h b/Source/PluginLookAndFeel.h
deleted file mode 100644
index a5448be..0000000
--- a/Source/PluginLookAndFeel.h
+++ /dev/null
@@ -1,24 +0,0 @@
-#pragma once
-
-#include
-
-namespace SuppressorTheme
-{
-inline const juce::Colour canvas { 0xff0b0e11 }, surface { 0xff12171c },
- surfaceRaised { 0xff171d23 }, divider { 0xff2a333c }, primaryText { 0xffe8edf1 },
- secondaryText { 0xff89939d }, controlOutline { 0xff66717b }, accent { 0xffb8f24a },
- warning { 0xffe7a84b }, error { 0xffff8080 };
-juce::Font makeFont (float size, bool semibold = false);
-}
-
-class SuppressorLookAndFeel final : public juce::LookAndFeel_V4
-{
-public:
- SuppressorLookAndFeel();
- void drawRotarySlider (juce::Graphics&, int, int, int, int, float, float, float,
- juce::Slider&) override;
- void drawButtonBackground (juce::Graphics&, juce::Button&, const juce::Colour&,
- bool, bool) override;
- juce::Font getTextButtonFont (juce::TextButton&, int) override;
- juce::Font getPopupMenuFont() override;
-};
diff --git a/Tests/test_clap_gui.cpp b/Tests/test_clap_gui.cpp
index 5dddd1a..76e0c04 100644
--- a/Tests/test_clap_gui.cpp
+++ b/Tests/test_clap_gui.cpp
@@ -1,5 +1,5 @@
#include
-#include
+#include "PluginEditor.h"
#include
namespace
@@ -40,7 +40,7 @@ TEST_CASE ("CLAP embeds, hides, reopens and destroys the actual native editor")
if (path.isEmpty()) return;
juce::ScopedJuceInitialiser_GUI initialiser;
juce::Component parent;
- parent.setBounds (80, 80, 640, 460);
+ parent.setBounds (80, 80, SuppressorEditor::width, SuppressorEditor::height);
parent.addToDesktop (0);
parent.setVisible (true);
ClapHost h;
@@ -79,8 +79,8 @@ TEST_CASE ("CLAP embeds, hides, reopens and destroys the actual native editor")
REQUIRE (h.created);
uint32_t width = 0, height = 0;
REQUIRE (h.gui->get_size (h.plugin, &width, &height));
- CHECK (width == 640);
- CHECK (height == 460);
+ CHECK (width == SuppressorEditor::width);
+ CHECK (height == SuppressorEditor::height);
CHECK_FALSE (h.gui->can_resize (h.plugin));
REQUIRE (h.gui->set_parent (h.plugin, &window));
REQUIRE (h.gui->show (h.plugin));
diff --git a/Tests/test_ui.cpp b/Tests/test_ui.cpp
index dbb5458..5a33796 100644
--- a/Tests/test_ui.cpp
+++ b/Tests/test_ui.cpp
@@ -55,6 +55,39 @@ void feed (juce::AudioProcessor& p, int blocks = 120, bool silence = false, bool
if (bypass) p.processBlockBypassed (buffer, midi); else p.processBlock (buffer, midi);
}
}
+bool capturing() { return juce::SystemStats::getEnvironmentVariable ("SUPPRESSOR_UI_CAPTURE_DIR", {}).isNotEmpty(); }
+// Plucked notes over a hiss floor, processed at real-time pace so the editor's
+// reduction history fills with readings the processor actually produced.
+void playPlucks (juce::AudioProcessor& p)
+{
+ struct Pluck { double at, level; };
+ const Pluck notes[] { { 0.0, 0.30 }, { 0.9, 0.18 }, { 1.5, 0.34 }, { 2.9, 0.22 }, { 3.4, 0.12 }, { 4.6, 0.32 }, { 5.3, 0.2 } };
+ constexpr double length = 6.2, fundamental = 196.0;
+ juce::AudioBuffer buffer (2, 256);
+ juce::MidiBuffer midi;
+ juce::Random hiss (7);
+ const auto start = juce::Time::getMillisecondCounterHiRes();
+ const auto blocks = static_cast (length * 48000.0 / 256.0);
+ for (int b = 0; b < blocks; ++b)
+ {
+ for (int n = 0; n < 256; ++n)
+ {
+ const auto t = (b * 256 + n) / 48000.0;
+ auto sample = 0.002 * (hiss.nextDouble() * 2.0 - 1.0);
+ for (const auto& note : notes)
+ if (t >= note.at)
+ for (int k = 1; k <= 30; ++k)
+ sample += note.level / k * std::exp (-(t - note.at) * (1.5 + 0.35 * k))
+ * std::sin (juce::MathConstants::twoPi * fundamental * k * (t - note.at));
+ buffer.setSample (0, n, static_cast (sample));
+ buffer.setSample (1, n, static_cast (sample));
+ }
+ p.processBlock (buffer, midi);
+ const auto due = start + (b + 1) * 256.0 / 48.0;
+ while (juce::Time::getMillisecondCounterHiRes() < due)
+ pump (2);
+ }
+}
juce::MouseEvent event (juce::Component& c, juce::Point pos = { 60, 60 }, int modifiers = juce::ModifierKeys::leftButtonModifier)
{
const auto now = juce::Time::getCurrentTime();
@@ -100,12 +133,21 @@ float contrast (juce::Colour a, juce::Colour b)
TEST_CASE ("theme retains bundled fonts and accessible contrast")
{
- using namespace SuppressorTheme;
- CHECK (makeFont (18).getTypefaceName() == "Barlow Condensed");
- CHECK (makeFont (18).getTypefaceStyle() != makeFont (18, true).getTypefaceStyle());
- for (auto colour : { primaryText, secondaryText, accent, warning, error })
- CHECK (contrast (colour, surface) >= 4.5f);
- CHECK (contrast (controlOutline, surfaceRaised) >= 3.0f);
+ CHECK (affine::fonts::label (18).getTypefaceName() == "Barlow Condensed");
+ CHECK (affine::fonts::label (18).getTypefaceStyle() != affine::fonts::text (18).getTypefaceStyle());
+ CHECK (affine::fonts::wordmark (18).getTypefaceName() == "Michroma");
+ CHECK (affine::fonts::readout (18).getTypefaceName() == "Share Tech Mono");
+ const auto theme = SuppressorTheme::theme();
+ const auto& palette = theme.palette;
+ // Printed legends on the panel, including its darker lower edge.
+ for (auto plate : { theme.panel.base, theme.panel.base.darker (0.25f) })
+ {
+ CHECK (contrast (palette.silkscreen, plate) >= 4.5f);
+ CHECK (contrast (palette.silkscreenDim, plate) >= 4.5f);
+ }
+ // Emitted readouts, the screen and lamps on display glass.
+ for (auto colour : { palette.accent, palette.screen, palette.attention, palette.danger, palette.silkscreen })
+ CHECK (contrast (colour, palette.glass) >= 4.5f);
}
TEST_CASE ("four controls preserve the complete host parameter and saved-state contract")
@@ -120,8 +162,8 @@ TEST_CASE ("four controls preserve the complete host parameter and saved-state c
"humLearn", "bandMode", "bandsLearn", "deltaAudition", "outputGain" };
const std::array defaults { .9f, -40, 6, 0, 40, 6, 2, 0, 0, 0, 0, 0, 0, 8, 1, 0, 0, 0, 0, 0 };
REQUIRE (p.getParameters().size() == ids.size());
- CHECK (editor.getWidth() == 640);
- CHECK (editor.getHeight() == 460);
+ CHECK (editor.getWidth() == SuppressorEditor::width);
+ CHECK (editor.getHeight() == SuppressorEditor::height);
CHECK_FALSE (editor.isResizable());
std::set focus;
int controls = 0;
@@ -164,7 +206,7 @@ TEST_CASE ("dials support balanced drag, fine, exact, cancel, invalid, reset and
auto editor = std::make_unique (p);
editor->addToDesktop (0);
editor->setVisible (true);
- auto* dial = dynamic_cast (editor->findChildWithID ("strength"));
+ auto* dial = dynamic_cast (editor->findChildWithID ("strength"));
REQUIRE (dial != nullptr);
auto& param = dial->parameter;
Gestures gestures;
@@ -183,7 +225,7 @@ TEST_CASE ("dials support balanced drag, fine, exact, cancel, invalid, reset and
CHECK (dial->getValue() == doctest::Approx (.61));
CHECK (dial->keyPressed (juce::KeyPress (juce::KeyPress::rightKey)));
CHECK (dial->getValue() == doctest::Approx (.62));
- auto* threshold = dynamic_cast (editor->findChildWithID ("threshold"));
+ auto* threshold = dynamic_cast (editor->findChildWithID ("threshold"));
REQUIRE (threshold != nullptr);
threshold->mouseDown (event (*threshold));
for (int pixel = 1; pixel <= 6; ++pixel)
@@ -309,13 +351,15 @@ TEST_CASE ("live meter states, stale data, legacy modes and monitor labels are e
feed (p);
expectStatus (*status, "Suppressing");
CHECK (summary->getDescription().contains ("High-band reduction"));
+ if (capturing())
+ playPlucks (p);
capture (*editor, "suppressor-ui");
for (float scale : { 1.0f, 1.25f, 1.5f, 1.75f, 2.0f })
{
editor->setScaleFactor (scale);
- CHECK (editor->getWidth() == 640);
+ CHECK (editor->getWidth() == SuppressorEditor::width);
CHECK (editor->getLocalBounds().toFloat().transformedBy (editor->getTransform()).getWidth()
- == doctest::Approx (640 * scale));
+ == doctest::Approx (SuppressorEditor::width * scale));
capture (*editor, "suppressor-scale-" + juce::String (juce::roundToInt (scale * 100)), scale);
}
editor->setScaleFactor (1);
@@ -405,7 +449,7 @@ TEST_CASE ("VST3 native editor lifecycle and host parameter state recall")
REQUIRE (p != nullptr);
prepare (*p);
juce::Component hostWindow;
- hostWindow.setBounds (80, 80, 640, 460);
+ hostWindow.setBounds (80, 80, SuppressorEditor::width, SuppressorEditor::height);
hostWindow.addToDesktop (0);
hostWindow.setVisible (true);
pump();
@@ -418,8 +462,8 @@ TEST_CASE ("VST3 native editor lifecycle and host parameter state recall")
hostWindow.addAndMakeVisible (*editor);
pump();
feed (*p); pump();
- CHECK (editor->getWidth() == 640);
- CHECK (editor->getHeight() == 460);
+ CHECK (editor->getWidth() == SuppressorEditor::width);
+ CHECK (editor->getHeight() == SuppressorEditor::height);
// This is the host's native embedding view, not the plugin's JUCE
// component tree. Exercise the real wrapper rather than casting it.
REQUIRE (p->getParameters().size() == 21); // includes VST3 wrapper bypass
diff --git a/assets/fonts/README.md b/assets/fonts/README.md
deleted file mode 100644
index 90e7f00..0000000
--- a/assets/fonts/README.md
+++ /dev/null
@@ -1,7 +0,0 @@
-# Barlow Condensed
-
-This directory contains Barlow Condensed Regular and SemiBold, version 1.422.
-
-- Source: https://github.com/jpt/barlow/tree/1.422
-- Copyright: The Barlow Project Authors
-- License: SIL Open Font License 1.1; see `OFL.txt`.
diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md
index 623ced2..8b3a1b6 100644
--- a/docs/UI_DESIGN.md
+++ b/docs/UI_DESIGN.md
@@ -1,209 +1,107 @@
-# Suppressor UI design brief
+# Suppressor interface
-This brief applies the HDN Plugin UI Design skill v0.1.1 to Suppressor 0.1.0. It documents the editor contract and does not modify the central design system.
+The editor implements the shared [Affine design language](../libs/affine-ui/DESIGN_LANGUAGE.md)
+(v3.0) through the vendored `affine_ui` JUCE module. This document records only the
+Suppressor-specific decisions.
+
+
+
+
## Product
-- **Purpose:** Zero-latency-by-default, frequency-split noise suppression for guitar DI before high-gain amplification.
-- **Primary musician task:** Remove high-band noise between notes without sacrificing body, sustain, or pick attack, then verify the removed signal when needed.
-- **Character:** Surgical, quiet, fast, spectral, trustworthy.
-- **Users:** Guitarists and engineers tracking or reamping mono/stereo DI, optionally with an external detector key.
-- **Three-second read:** Product identity; active topology; suppression activity; Strength, Threshold, and Release; Delta Audition state.
-- **Branding:** The existing Suppressor Audio identity is retained. The HDN grammar is used without adding an unapproved HDN product mark.
-
-## Delivery environment
-
-- **Framework:** JUCE 8.0.9.
-- **CLAP wrapper:** clap-juce-extensions `c1a5ad025f95d01e03267857fa8276ebeed16500`.
-- **Release formats:** VST3 and CLAP.
-- **Design/test harness:** Standalone; it is not packaged by the current release workflow.
-- **Platforms:** macOS, Windows, and Linux.
-- **Editor policy:** Fixed at 1000 x 640 logical pixels.
-- **Scale factors:** 100%, 125%, 150%, 175%, and 200% where the host/platform supports them.
-- **Input:** Mouse, trackpad, keyboard, and assistive technology.
-- **Typography:** Bundled Barlow Condensed 1.422 Regular and SemiBold under the SIL Open Font License 1.1.
-
-## Immutable functional contract
-
-The UI work must preserve:
-
-- every file under `Source/DSP/` and all audio-thread behavior;
-- `PluginProcessor.*`, the APVTS state type `Parameters`, state serialization, and session compatibility;
-- manufacturer/plugin codes `SpAu`/`Supr` and CLAP ID `audio.suppressor.suppressor`;
-- all 20 parameter IDs, version hints, order, names, types, ranges, defaults, tapers, steps, units, text conversion, and automation behavior;
-- buses, sidechain behavior, MIDI declarations, latency reporting, and wrapper identity;
-- CLAP event resolution and wrapper revision.
-
-## Parameter inventory
-
-| ID | Purpose | Range / default / text | Dependency |
-|---|---|---|---|
-| `strength` | One-split crossover strength | 0–100%, 90%, linear | Inactive but editable in 4/6-Band |
-| `threshold` | One-split detector threshold | -80–0 dB, -40 dB, 0.1 dB | Inactive but editable in 4/6-Band |
-| `release` | Gate release | 2–30 ms, 6 ms, exponential | Active in every topology |
-| `gateMode` | Reduction or Tight Gate | Reduction / Tight Gate, Reduction | Depth is ignored by Tight Gate |
-| `depth` | Reduction-mode floor | 0–60 dB, 40 dB, 0.1 dB | Inactive but editable in Tight Gate |
-| `hysteresis` | Open/close threshold separation | 0–24 dB, 6 dB, 0.1 dB | Active |
-| `hold` | Retriggerable hold | 0–50 ms, 2 ms, 0.1 ms | Active |
-| `adaptiveRelease` | Crest-dependent release | Off / on, off | Active |
-| `cue` | Broadband transient cue | 0–100%, 0%, integer text | Active |
-| `sidechain` | Detector source | Internal / External Key, Internal | External selection does not prove key presence |
-| `lookahead` | Detector lookahead | 0 / 1 / 2 ms, 0 ms | Changes reported latency |
-| `humEnable` | Enable learned hum dips | Off / on, off | Lock is also required for audible removal |
-| `humBase` | Auto/50/60 Hz family | Auto / 50 Hz / 60 Hz, Auto | A change invalidates an existing lock |
-| `humHarmonics` | Maximum hum harmonics | 1–16, 8, integer | Inactive but editable while Hum Removal is off |
-| `humStrength` | Learned dip strength | 0–100%, 100% | Inactive but editable while Hum Removal is off |
-| `humLearn` | Start/stop hum capture | Boolean, off | Works while Hum Removal is off; hum stage passes during capture |
-| `bandMode` | Suppression topology | One-Split / 4-Band / 6-Band, One-Split | Determines core applicability |
-| `bandsLearn` | Start/stop band threshold capture | Boolean, off | Editable/pre-armable in One-Split; capture is effective only while processing in 4/6-Band, where the suppression stage passes |
-| `deltaAudition` | Output removed signal | Off / on, off | Critical output state |
-| `outputGain` | Final gain | -12–12 dB, 0 dB, 0.1 dB | Active |
-
-All parameters retain version hint 1 and their current automatable state.
-
-## Mode and state inventory
+- **Purpose:** zero-latency, frequency-split noise suppression for guitar DI before
+ high-gain amplification.
+- **Primary task:** remove high-band noise between notes without losing body, sustain or
+ pick attack, and check what was removed when in doubt.
+- **Screen colour:** teal. It appears only on the screen and the *Suppressing* LED; the
+ rest of the panel is the family's matte black hardware.
+- **Editor:** fixed 820 × 540 logical pixels; renders natively at 100–200% and on Retina.
-| State | Presentation |
+## Layout
+
+| Zone | Contents |
|---|---|
-| Reduction | Depth uses the periwinkle active arc. |
-| Tight Gate | Depth remains operable in place with a neutral arc and an accessible “inactive but editable” explanation. |
-| One-Split | Strength, global Threshold, and detector/threshold activity are active. |
-| 4/6-Band | Strength and global Threshold remain in place with neutral arcs. The stale one-split detector value is replaced by “Global level not exposed”; gain reduction remains visible. |
-| Hum off | Harmonics and Hum Strength remain editable with neutral arcs. |
-| Hum learning armed | Learn becomes “Stop Learning”; status says the request is armed and capture/pass-through occurs only while processing. No lock-success claim is made. |
-| Multiband learning armed | Learn becomes “Stop Learning”; Activity marks gain reduction unavailable and says suppression passes while processing. No lock-success claim is made. |
-| One-Split + Bands Learn | The parameter remains editable and can be armed for automation/preconfiguration; status says it is waiting for 4/6-Band. |
-| Delta Audition | Activity status explicitly reads “OUTPUT: REMOVED SIGNAL” in addition to the selected Delta Audition control. |
-| External Key | The selected source is shown without claiming that a sidechain is connected. |
-
-The processor does not expose bypass, key-presence, lock validity, learn completion, or a processing heartbeat through a safe immutable UI snapshot. The editor therefore makes no claims about those states. It labels activity as a last-processing snapshot, treats enabled Learn parameters as armed requests, and does not read the existing non-atomic engine status accessors.
+| Header | Wide-tracked wordmark and descriptor; the processing status with its LED; *Host settings active* underneath when non-default legacy settings exist |
+| Screen | Left: **high-band reduction** over the last six seconds, with the current value above the plot. Right: **INPUT** and **OUTPUT** peak bars (the output relabelled **REMOVED** while auditioning), each with a readout and a clip cell |
+| Controls | Threshold, Strength, Release: medium knobs with LED rings and glass readouts; after a hairline, *Listen removed* as a soft key with a headphones glyph |
## Telemetry contract
-| Datum | Source | Decision | Validity and presentation |
-|---|---|---|---|
-| Detector level | `detectorDbAtomic` | Place the One-Split threshold relative to incoming high-band energy | Numeric and bar in One-Split; not shown as current in 4/6-Band because the source is stale there; <= -120 dB is “No Signal” |
-| Gain reduction | `grDbAtomic` | See how much high-band attenuation is being applied | Positive magnitude, 0–60 dB display range; `60+ dB` avoids false precision in Tight Gate |
-| Threshold marker | Existing `threshold` parameter | Compare detector level with the configured threshold | White marker on the detector bar in One-Split only |
-| Learn armed | APVTS-backed Learn button state | Know that capture is requested and stop it | Text plus latched shape; capture/pass-through is explicitly conditional on processing; no invented progress or lock-success claim |
-
-The display repaints only when rounded values or semantic state changes and announces semantic transitions rather than meter samples.
-
-## Information architecture
-
-The dominant path is header → Suppression Core → Activity → Detection/Hum/Multiband and Output.
-
-- **Suppression Core:** A wide primary panel contains Gate Mode, Adaptive Release, and the six controls that determine the core suppression behavior. This prioritizes the main musical decision rather than mirroring implementation classes.
-- **Activity:** The first accessibility group and a dedicated top-level panel present the last processing snapshot, with stale one-split data and learning pass-through handled explicitly rather than through a decorative analyzer.
-- **Detection:** Cue, source, and lookahead stay together.
-- **Hum Filter:** Enable, mains family, harmonics, strength, Learn, and capture state form one stable zone.
-- **Multiband / Output:** Topology and learning share a compact upper zone; final gain and the critical Delta state sit below a divider.
+All readings come from the processor's lock-free `MeterSnapshot`. A reading is live only
+when a *new* audio block arrived within 500 ms and its routing flags match the current
+Listen and band-mode settings, so an old block is never relabelled as the new mode.
-No mode moves a control. Spacing and surface hierarchy replace the previous six equal GroupComponent boxes.
+| Display | Source | Presentation |
+|---|---|---|
+| Reduction plot | Deepest high-band gain reduction, 120 ms decay, sampled 30 times a second while the editor is open | 0–60 dB on a square-root scale, 0 dB at the top, newest at the right; the plot breaks wherever reduction was unavailable (no audio, no input, learning) and reads *NO AUDIO* once six seconds hold no readings |
+| Reduction readout | The latest reading | dB to 0.1, `60+` beyond the scale, `--` when unavailable |
+| INPUT / OUTPUT bars | Peak across channels, 300 ms decay | −60 to 0 dBFS in 1.5 dB segments: teal, pale from −12 dBFS, amber from −3 dBFS |
+| Clip cells | Processor clip flags, held for one second | Separate red cell at the end of each bar |
+| Peak readouts | Same peaks | dBFS to 0.1 dB, `-inf` for silence, `--` when not live |
+| Status | Snapshot flags | *No audio*, *No input*, *Passing signal*, *Suppressing*, *Listening to difference*, *Bypassed*, *Learning bands* |
-## Family system and tokens
+The plot shows stored readings only: nothing is interpolated across gaps or extrapolated
+past the newest reading.
-The implementation retains the family’s flat surfaces, bundled typography, centered rotary values, 4 px rhythm, 18 px outer margin, 12 px gaps, semantic focus, and exact-entry behavior. It intentionally replaces the Ring Modulator lime with a product-specific **spectral periwinkle** system:
+## States
-| Role | Token | Contrast evidence |
-|---|---:|---|
-| Canvas | `#0A0E14` | — |
-| Header | `#0D141E` | — |
-| Panel | `#111A24` | — |
-| Raised control | `#182432` | — |
-| Decorative divider | `#2A3A4B` | Not an interactive boundary |
-| Essential control outline | `#66798C` | 3.5:1 on raised controls |
-| Primary text | `#F1F5F8` | 16.0:1 on panels |
-| Secondary text | `#A6B2BE` | 8.1:1 on panels |
-| Active/focus accent | `#8EA7FF` | 7.6:1 on panels |
-| Inactive-editable track | `#6C7C8F` | 4.1:1 on panels |
-| Warning/acquiring | `#F2B84B` | 9.8:1 on panels |
-| Error | `#FF6F7D` | 6.5:1 on panels |
-
-Color is paired with text, fill extent, marker geometry, a checkmark, or a persistent 2 px focus outline.
-
-## Interaction matrix
-
-| Control | Pointer | Fine / keyboard | Exact entry | Reset / wheel | Focus |
-|---|---|---|---|---|---|
-| Rotary | Full dial, centered value, arc, and interior drag; up/right increases | Shift-drag uses velocity fine mode; arrows retain JUCE parameter steps | Double-click anywhere or Return; Return commits, Escape cancels; invalid text preserves the current value | Alt/Option-click restores the APVTS default; wheel disabled | Explicit tab order and 2 px outer ring |
-| Selector | Full 34 px control opens | Arrows navigate; Return selects; Escape dismisses | n/a | Host parameter mapping retained | 2 px outline plus arrow color/shape |
-| Toggle | Full 34–38 px tile | Space toggles | n/a | Host-notifying ButtonAttachment | 2 px outline plus checkmark |
-| Learn action | Full 36 px action | Space/Return operates | n/a | APVTS ButtonAttachment supplies host gesture/state sync | Unique Learn/Stop Learning name and latched fill/text; armed is not mislabeled as capturing |
-
-`getControlParameterIndex` maps each control and centered value child to its existing processor parameter for host services.
+| State | Presentation |
+|---|---|
+| Suppressing | Teal status LED, the plot follows the reduction, bars live |
+| Listen removed | Amber LED strip on the key and amber status LED; the output bar reads **REMOVED** in amber |
+| Multiband topology | The plot title reads **MAX BAND REDUCTION**; Threshold and Strength are disabled (their rings go dark) because that mode uses its own splits and learned thresholds |
+| Learning bands | Amber status LED; reduction is unavailable, so the plot breaks |
+| Bypassed | Red status LED; the plot runs along 0 dB (no reduction is applied) |
+| Stopped audio | Status LED off, bars dark, readouts `--`, the plot breaks |
+
+
+
+
+
+
+## Parameter contract
+
+Unchanged: all 20 parameters keep their IDs, order, version hints, ranges, defaults,
+tapers, text conversion and automation. The editor exposes Threshold, Strength, Release
+and Delta Audition; the remaining engine settings stay available in the host's parameter
+view and are summarised by the *Host settings active* tooltip.
+
+## Interaction
+
+- Knobs follow the family contract: drag anywhere (up or right increases; 240 px for the
+ full range, Shift for ten times finer), double-click or Return for exact entry in the
+ readout (Return commits, Escape cancels, malformed text never changes the value),
+ Alt/Option-click or Home to reset, arrows to step (Strength 1%, Threshold and Release
+ 0.1), right-click for *Enter value*, *Reset to default* and the host's parameter menu.
+- *Listen removed* toggles with a click, Space or Return, as one host gesture.
+- Tab order: Threshold, Strength, Release, Listen removed. Focus shows as an illuminated
+ ring at the foot of the knob or an outline around the key.
+
+
+
+
## Accessibility
-- Every interactive component has a unique name, title, formatted parameter value/unit, description, and explicit focus order.
-- Decorative labels are hidden from the accessibility tree to prevent duplicate announcements.
-- All interactive targets are at least 34 px high; rotary targets are substantially larger.
-- Inactive-but-editable controls remain enabled and explain why they currently have no audible effect.
-- Critical states use text plus geometry; no state relies on color alone.
-- The Activity display comes before controls in accessibility browse order and exposes a read-only last-snapshot value. Meter samples are queryable, but only semantic transitions announce.
-- `EDITOR_WANTS_KEYBOARD_FOCUS` is enabled; embedded VST3 and CLAP focus and text-entry checks pass.
-
-## Host-format validation
-
-| Format | Artifact / validator | Manual host evidence | Result |
-|---|---|---|---|
-| VST3 | Release `suppressor.vst3`; pluginval 1.0.4 strictness 10 | JUCE 8.0.9 AudioPluginHost: embedded exact commit/cancel/invalid preservation, Tab and arrow paths, Alt/Option reset, normal and Shift-fine drag, host graph state save/recall, close/reopen, fixed-size rejection, and graceful null-context fallback | Pass |
-| CLAP | Release `suppressor.clap`; clap-validator 0.4.1 | Pinned-header Cocoa integration host: 1000 x 640 logical size, non-resizable contract, 24 named accessibility elements, Tab/Space, exact entry, reset, normal/fine drag, balanced begin/value/end events, host-driven `params.flush` UI update, and repeated creation | Pass with wrapper debt noted below |
-| Standalone | Release `suppressor.app` | Design/accessibility harness: complete state captures, AX inspection, pointer/keyboard paths, close-during-drag coverage, and fixed geometry | Pass; not a shipped format |
-
-VST3 and CLAP are treated as separate wrappers. Validator results supplement rather than replace the embedded-editor checks. Cocoa CLAP sizes are logical and the pinned CLAP contract does not permit `set_scale` for that native API.
-
-## Verification evidence
-
-### Captured states
-
-- [Default CLAP editor](images/suppressor-ui.png)
-- [Multiband learning armed](images/suppressor-learning.png)
-- [Persistent keyboard focus during learning](images/suppressor-learning-focus.png)
-- [Delta Audition with `OUTPUT: REMOVED SIGNAL`](images/suppressor-delta-audition.png)
-- [Centered exact-value entry](images/suppressor-exact-entry.png)
-- [VST3 editor embedded in JUCE AudioPluginHost](images/suppressor-vst3-host.png)
-- Scale renders: [100%](images/suppressor-scale-100.png), [125%](images/suppressor-scale-125.png), [150%](images/suppressor-scale-150.png), [175%](images/suppressor-scale-175.png), and [200%](images/suppressor-scale-200.png)
-
-### Automated and manual checks
-
-- Nine production-linked UI unit/integration cases pass with 363 assertions. They cover the bundled fonts, contrast, centered value hit testing, strict exact parsing, default reset, all 20 APVTS controls, accessible names and parameter mapping, truthful topology/learning/Delta states, keyboard gestures including Space, close-during-drag teardown, fixed logical geometry, and complete rendering at every declared scale.
-- The existing DSP suite passes unchanged.
-- Release VST3, CLAP, and Standalone targets build successfully.
-- pluginval 1.0.4 at strictness 10 reports `SUCCESS`.
-- clap-validator 0.4.1 reports 32 passed, 0 failed, one warning, and 11 skipped.
-- The generated VST3 definition contains `JucePlugin_EditorRequiresKeyboardFocus=1`.
-- In both hosted formats, Strength exact entry commits `20%`; Escape and malformed suffix text preserve the prior value; Right changes `20%` to `21%`; Alt/Option resets to `90%`; a normal 10 px drag changes `50%` to `54.5%`; and a Shift 50 px drag changes it to `50.2%`.
-- CLAP output records exactly balanced begin/value/end events for Space, exact entry, arrows, reset, and drag. A host-originated `params.flush` change to `42%` updates the visible control without a feedback loop.
-- VST3 host graph save/load restores Strength from `72%` to the saved `33%`, and editor close/reopen preserves `20%`.
-- The fixed editor rejects host-window resize attempts in VST3 and CLAP. The scale test keeps 1000 x 640 logical bounds while rendering complete transformed bounds at 100%, 125%, 150%, 175%, and 200%.
-- CI runs the UI render suite and uploads all declared-scale PNGs on macOS, Windows, and Linux; Linux uses Xvfb. CI also validates both shipped formats on all three platforms.
-
-## HDN review scorecard
-
-| Dimension | Score | Evidence / debt |
-|---|---:|---|
-| Truth and state | 2/2 | Last-snapshot, armed-request, conditional pass-through, unavailable reduction, and Delta output wording match exposed state. |
-| Hierarchy and grouping | 2/2 | Stable topology and a clear core/activity/supporting-zone order. |
-| Restraint and family fit | 2/2 | Flat technical surfaces and product-specific periwinkle without analyzer theater or unapproved branding. |
-| Control and interaction clarity | 2/2 | Full targets, units, exact/reset/fine paths, focus, and neutral inactive-but-editable styling are explicit. |
-| Typography and scaling | 2/2 | Bundled fonts and complete 100–200% scale renders have no clipping, crowding, or jitter. |
-| Accessibility | 2/2 | Contrast, 20 unique controls, semantic Activity-first browse order, full keyboard paths, non-color cues, and persistent focus geometry pass. |
-| Host correctness and performance | 2/2 | Separate embedded VST3/CLAP passes, balanced gestures, host synchronization/state recall, fixed sizing, bounded timers, validators, and immutable processor/DSP audit pass. |
-| Polish and consistency | 2/2 | Alignment, rhythm, formatting, state styling, and interaction feedback form one coherent system. |
-| **Total** | **16/16** | **SHIP for the UI boundary.** Pre-existing release metadata/signing debt remains outside this patch. |
-
-## Pre-existing release and wrapper debt
-
-These findings reproduce without the UI patch and are not silently broadened into this editor-only change:
-
-1. JUCE derives `com.Suppressor Audio.Suppressor`, whose space makes the macOS VST3 bundle identifier invalid for notarized release. Product/manufacturer codes and the CLAP ID remain intentionally unchanged here.
-2. Baseline and final macOS CLAP/Standalone bundles report `code has no resources but signature indicates they must be present`. Standalone is not packaged; release signing remains an owner follow-up.
-3. clap-validator's random-state warning and the pinned wrapper's choice/integer metadata behavior are unchanged wrapper debt; deterministic state and UI synchronization checks pass.
-4. The pinned CLAP wrapper reports `gui.hide == false` for Cocoa while `show`, host-window concealment, destruction, and fresh creation operate. This is recorded as wrapper behavior rather than presented as a UI state.
-
-## Open constraints and design-system candidates
-
-1. Safe lock/base/notch and band-lock snapshots require a separate processor transport change, which is outside this UI-only boundary.
-2. Host bypass and external-key validity are not exposed; the UI does not invent them.
-3. Product-specific accent variation and the neutral “owned by another topology” treatment are candidates for the central design system only after review across more plugins.
+- Every control exposes its parameter name, formatted value with unit, and a description.
+- The *Signal meters* label carries a live summary (input and output dBFS, reduction or
+ why it is unavailable, clip warnings); the screen itself is hidden from assistive
+ technology so values are not announced twice.
+- State is never colour-only: the status is always spelled out next to its LED, and
+ every mode change has a printed or displayed label.
+- Printed text meets 4.5:1 on the plate, including its darker lower edge; emitted
+ colours meet 4.5:1 on display glass. Both are unit-tested.
+
+## Verification
+
+- `SuppressorUITests` (doctest, 8 cases) covers bundled fonts and contrast, the complete
+ parameter and saved-state contract, balanced drag, fine, exact, invalid, reset, keyboard
+ and close gestures, truthful live, stale, bypass, learning and audition states, clip
+ reporting, rendering at 100–200%, and the native VST3 and CLAP editor lifecycle.
+- The DSP suite is unchanged and passes.
+- pluginval 1.0.4 at strictness 10 reports `SUCCESS` for the VST3.
+- With `SUPPRESSOR_UI_CAPTURE_DIR` set, the UI tests write a PNG of every state and scale;
+ before the main capture they play six seconds of plucked notes at real-time pace, so
+ the plot shows readings the processor produced. CI uploads the captures for macOS,
+ Windows and Linux.
diff --git a/docs/images/suppressor-delta-audition.png b/docs/images/suppressor-delta-audition.png
index 7329159..6784a40 100644
Binary files a/docs/images/suppressor-delta-audition.png and b/docs/images/suppressor-delta-audition.png differ
diff --git a/docs/images/suppressor-exact-entry.png b/docs/images/suppressor-exact-entry.png
index c298e32..7427b6e 100644
Binary files a/docs/images/suppressor-exact-entry.png and b/docs/images/suppressor-exact-entry.png differ
diff --git a/docs/images/suppressor-learning-focus.png b/docs/images/suppressor-learning-focus.png
deleted file mode 100644
index 4a1d924..0000000
Binary files a/docs/images/suppressor-learning-focus.png and /dev/null differ
diff --git a/docs/images/suppressor-learning.png b/docs/images/suppressor-learning.png
index 7090b85..9fd3f21 100644
Binary files a/docs/images/suppressor-learning.png and b/docs/images/suppressor-learning.png differ
diff --git a/docs/images/suppressor-scale-100.png b/docs/images/suppressor-scale-100.png
deleted file mode 100644
index 6c0ee39..0000000
Binary files a/docs/images/suppressor-scale-100.png and /dev/null differ
diff --git a/docs/images/suppressor-scale-125.png b/docs/images/suppressor-scale-125.png
deleted file mode 100644
index 385eebb..0000000
Binary files a/docs/images/suppressor-scale-125.png and /dev/null differ
diff --git a/docs/images/suppressor-scale-150.png b/docs/images/suppressor-scale-150.png
deleted file mode 100644
index 7918ab9..0000000
Binary files a/docs/images/suppressor-scale-150.png and /dev/null differ
diff --git a/docs/images/suppressor-scale-175.png b/docs/images/suppressor-scale-175.png
deleted file mode 100644
index cc69641..0000000
Binary files a/docs/images/suppressor-scale-175.png and /dev/null differ
diff --git a/docs/images/suppressor-scale-200.png b/docs/images/suppressor-scale-200.png
deleted file mode 100644
index c00d73d..0000000
Binary files a/docs/images/suppressor-scale-200.png and /dev/null differ
diff --git a/docs/images/suppressor-ui.png b/docs/images/suppressor-ui.png
index eac0e87..5132457 100644
Binary files a/docs/images/suppressor-ui.png and b/docs/images/suppressor-ui.png differ
diff --git a/docs/images/suppressor-vst3-host.png b/docs/images/suppressor-vst3-host.png
deleted file mode 100644
index e139626..0000000
Binary files a/docs/images/suppressor-vst3-host.png and /dev/null differ
diff --git a/libs/affine-ui/AffineUI.cmake b/libs/affine-ui/AffineUI.cmake
new file mode 100644
index 0000000..7ab89ef
--- /dev/null
+++ b/libs/affine-ui/AffineUI.cmake
@@ -0,0 +1,17 @@
+# Affine UI: the shared rendered-instrument design language for Affine plug-ins.
+# include() this file after JUCE is available, then link the `affine_ui` target.
+include_guard(GLOBAL)
+
+set(AFFINE_UI_ROOT "${CMAKE_CURRENT_LIST_DIR}")
+
+juce_add_binary_data(affine_ui_fonts
+ NAMESPACE AffineFonts
+ HEADER_NAME AffineFonts.h
+ SOURCES
+ "${AFFINE_UI_ROOT}/fonts/BarlowCondensed-Regular.ttf"
+ "${AFFINE_UI_ROOT}/fonts/BarlowCondensed-SemiBold.ttf"
+ "${AFFINE_UI_ROOT}/fonts/Michroma-Regular.ttf"
+ "${AFFINE_UI_ROOT}/fonts/ShareTechMono-Regular.ttf")
+
+juce_add_module("${AFFINE_UI_ROOT}/affine_ui")
+target_link_libraries(affine_ui INTERFACE affine_ui_fonts)
diff --git a/libs/affine-ui/DESIGN_LANGUAGE.md b/libs/affine-ui/DESIGN_LANGUAGE.md
new file mode 100644
index 0000000..9857978
--- /dev/null
+++ b/libs/affine-ui/DESIGN_LANGUAGE.md
@@ -0,0 +1,172 @@
+# Affine design language
+
+**Version 3.0** · shared by every Affine plug-in · implemented by the `affine_ui` JUCE module in this folder
+
+Affine plug-ins are **rendered instruments** built as modern matte hardware: a black
+powder-coated panel, dark knobs inside rings of white LEDs, rubber soft keys, and one
+colour screen per product. Colour lives on the screen; everything else is monochrome, so
+what the processor is doing is the brightest thing on the panel. The hardware is a
+vehicle for clarity, not decoration: every LED, plot and digit reports real state.
+
+
+
+
+
+## Principles
+
+1. **One light.** A single softbox sits above and slightly left of the player. Every
+ highlight, reflection and shadow in every product agrees with it, so the instruments
+ read as physical objects and sit together on screen.
+2. **Real materials, rendered.** Powder coat, spun-aluminium caps on anodised grips,
+ rubber keys, LEDs and screen glass, shaded procedurally at the display's physical
+ resolution, never stretched bitmaps.
+3. **Truth before spectacle.** An instrument only shows what the processor actually did.
+ Screens plot stored readings and break where there were none; stale readings go dark;
+ nothing is interpolated, extrapolated or animated for effect.
+4. **Colour means information.** The screen is the only coloured surface. LEDs are white;
+ amber and red are reserved for attention and clipping, and every coloured state is
+ also spelled out in text.
+5. **Printed like an instrument.** Labels are printed on the panel, scale numbers are
+ placed through the parameter's own mapping (a log control gets a log scale), and
+ controls are grouped by stage in signal-flow order, left to right.
+6. **One hardware kit.** Products share the panel, knobs, keys, LEDs, screen glass and
+ typography. They differ only in their screen colour and layout.
+7. **Native behaviour is part of the design.** Every control has the same drag, fine,
+ exact-entry, reset, keyboard and host-menu behaviour, and every edit is one host gesture.
+8. **Accessible by construction.** Every control has a role, name, formatted value and
+ keyboard path; critical state is never carried by colour alone; text meets 4.5:1.
+9. **Cheap to keep on screen.** Static layers are cached per physical scale; only
+ dynamic parts repaint, and only when their value changes. The audio thread publishes
+ atomics and never waits for the UI.
+
+## Anatomy of an instrument
+
+```
+┌──────────────────────────────────────────────────────────────────────┐
+│ W O R D M A R K ● status / mode keys │ header
+│ DESCRIPTOR │
+│ ╭──────────────────────────────────────────────────────────────────╮ │
+│ │ SCREEN: what the processor is doing, in the product's colour │ │ the hero
+│ │ captions · plots and scopes · numerals · segmented bars │ │
+│ ╰──────────────────────────────────────────────────────────────────╯ │
+│ STAGE ───────────────── STAGE ─────────────────── OUTPUT ────── │ what you can change,
+│ ◎ knob ◎ knob ◎ knob ◎ knob ▭ ▭ ◎ knob │ in signal-flow order
+│ ▱ affine │ maker's mark
+└──────────────────────────────────────────────────────────────────────┘
+```
+
+- **Header:** the wide-tracked product name and its descriptor on the left; the product's
+ status or primary mode on the right.
+- **Screen:** the largest area; it shows what the plug-in is doing. See *The screen*.
+- **Controls:** grouped by stage under section titles with a hairline, in signal-flow
+ order. Knob axes in a row share one horizontal line, as they would on a real panel.
+- **Maker's mark** bottom left. No screws or other fixings are visible.
+
+## Tokens
+
+### Palette
+
+| Role | Value | Use |
+|---|---|---|
+| Panel | `#17181B` | Matte powder coat |
+| Silkscreen | `#D8DBDF` | Product name, labels, key legends |
+| Silkscreen dim | `#8A9097` | Descriptor, section titles, scale numbers |
+| Accent | `#EEF4FF` | LED rings, key strips, readouts under knobs, focus |
+| Screen | per product | Everything on the screen |
+| Attention | `#FFB238` | Engaged monitoring or learning, hot segments |
+| Danger | `#FF4A3D` | Clip cells and bypass only |
+| Glass | `#050607` | Screen and readout glass |
+
+| Product | Screen |
+|---|---|
+| Suppressor | Teal `#35E0C8` |
+| HDN Ring Modulator | Amber `#FFB23F` |
+
+A new product picks a screen hue distinct from the others and from attention and danger,
+at least 4.5:1 on glass. Printed text is at least 4.5:1 against the panel (checked
+against its darker lower edge too), and emitted colours are at least 4.5:1 against
+glass; both are unit-tested per product.
+
+### Typography
+
+| Role | Face | Size |
+|---|---|---|
+| Product name | Barlow Condensed SemiBold, tracked 0.45 | 30 px, capitals |
+| Descriptor | Barlow Condensed SemiBold, tracked 0.30 | 11.5 px, capitals |
+| Control labels, section titles, captions | Barlow Condensed SemiBold, tracked 0.16–0.26 | 11.5–12.5 px, capitals |
+| Scale numbers, axis labels | Barlow Condensed SemiBold | 9.5–10 px |
+| Readouts and screen numerals | Share Tech Mono | 14–19 px; screen numerals 32–54 px |
+| Maker's mark | Michroma, tracked 0.32 | 9.5 px |
+
+All faces are bundled under the SIL Open Font License; see `fonts/licenses`.
+
+### Geometry
+
+| Token | Value |
+|---|---|
+| Knob sizes | large 84, medium 72, small 58 px (`metrics::knobLarge` …) |
+| Knob sweep | 270°, 7 o'clock to 5 o'clock, up/right increases |
+| LED ring | 23 LEDs, 7.5 px outside the knob; scale numbers 17 px outside |
+| Screen | 4 px bezel, 3 px glass corners; 14–18 px inner margin |
+| Display corners | 4 px |
+| Outer margin | 36–40 px |
+
+## The screen
+
+Each product draws its own screen on `render::screenGlass` in its screen colour, with the
+helpers in `affine::screen`:
+
+- **Captions** name each area and reading in capitals at 85% (`screen::caption`); units
+ and axis labels are smaller and dimmer.
+- **Numerals** that report a reading are lit: the screen colour lifted towards white
+ (`screen::lit`), with a soft glow, and dimmed to `--` without a reading.
+- **Plots and scopes** draw a 1.6 px trace over a 5 px halo, with a faint grid. They show
+ only stored readings: a history breaks wherever there was no reading, and an empty one
+ says so (`NO AUDIO`).
+- **Bars** are segmented (`screen::segments`); a cell lights when the level reaches its
+ centre. Hot cells are amber and clip cells red, held for as long as the processor holds
+ the clip.
+- **Dividers** between areas are 1 px rules at 10% of the screen colour.
+
+## Components
+
+| Component | Contract |
+|---|---|
+| `Knob` | Rendered knob inside an LED ring lit up to its value, with printed scale numbers, a label and a glass readout. Drag anywhere (up/right increases), Shift for fine, double-click or Return for exact entry (Return commits, Escape cancels, malformed text never changes the value), Alt/Option-click or Home resets, arrows step, right-click opens the parameter menu with host items. Optional activity lamp for *inactive but editable* states. |
+| `KeyButton` | Latching rubber key with an LED strip, an optional glyph and a printed legend; Space and Return operate it. |
+| `SelectorKeys` | One key per choice of a choice parameter, in a row or grid; each press is one complete gesture; arrows move the selection. |
+| `DisplayLabel` | A `juce::Label` shown as glowing characters with a status LED; the accessible text stays the label text. |
+| `Faceplate` | Caches the panel texture, its edge and everything printed on it per physical scale. |
+| `silkscreen::*` | Product name and descriptor, section titles, maker's mark. |
+| `screen::*` | Captions, lit colour and segmented bars for product screens. |
+| `render::*` | Panel texture, soft shadows, recesses, screen glass, LEDs, glow and halos. |
+| `LookAndFeel` | Menus, tooltips and text entry in the family style. |
+
+## States
+
+| State | Presentation |
+|---|---|
+| Live | LEDs lit, numerals glow, plots advance |
+| No data or stale (no processed audio for 400–500 ms) | Status LED off, numerals `--`, bars dark, plots break |
+| Inactive but editable (the control does not act in the current mode) | Activity lamp off, label, LED ring and readout dimmed; the control stays fully operable |
+| Disabled | Knob veiled, ring dark; not operable |
+| Attention | Amber LED plus explicit text (e.g. `LISTENING TO DIFFERENCE`) |
+| Clip | Red clip cell, held by the processor for one second, plus accessible text |
+| Keyboard focus | White ring at the foot of the knob, outline around keys |
+
+## Adding a product
+
+1. Copy this folder unchanged into the plug-in as `libs/affine-ui`, `include()` its
+ `AffineUI.cmake` after JUCE and link `affine_ui`.
+2. Start from the default `affine::Theme` and set `palette.screen` to the product's colour.
+3. Lay out the header, the screen and the control stages as above.
+4. Publish telemetry from the processor through atomics with a freshness signal (a block
+ counter or sequence) and present stale data as dark.
+5. Add tests for contrast, control mapping and focus order, and a capture test that writes
+ PNGs of every state for review.
+
+## Keeping products in sync
+
+This folder is identical in every Affine plug-in repository. Change it in one place, bump
+the version in `affine_ui/affine_ui.h` and this document, and copy the folder to every
+product in the same change set. `diff -r` between two checkouts must be empty.
diff --git a/libs/affine-ui/README.md b/libs/affine-ui/README.md
new file mode 100644
index 0000000..d647cc7
--- /dev/null
+++ b/libs/affine-ui/README.md
@@ -0,0 +1,49 @@
+# affine-ui
+
+The shared interface kit for Affine plug-ins: a JUCE module (`affine_ui`), its bundled
+typefaces, and the [design language](DESIGN_LANGUAGE.md) it implements.
+
+This folder is **vendored identically** into every Affine plug-in repository. Do not make
+product-specific edits here; put those in the product's editor. See
+[Keeping products in sync](DESIGN_LANGUAGE.md#keeping-products-in-sync).
+
+## Use
+
+```cmake
+# After JUCE is available:
+include(libs/affine-ui/AffineUI.cmake)
+target_link_libraries(MyPlugin PRIVATE affine_ui)
+```
+
+```cpp
+#include
+
+affine::Theme theme; // the family look
+theme.palette.screen = juce::Colour (0xff35e0c8); // the product's screen colour
+
+affine::Knob threshold { apvts, "threshold", "What this control does." };
+threshold.setTheme (theme);
+threshold.setDiameter (affine::metrics::knobMedium);
+threshold.setScale ({ -80, -60, -40, -20, 0 }, [] (double v) { return juce::String (juce::roundToInt (v)); });
+```
+
+Requires JUCE 8 and C++17. The module depends on `juce_gui_basics` and
+`juce_audio_processors`.
+
+## Contents
+
+| Path | What |
+|---|---|
+| `affine_ui/render` | Procedural renderers: knob materials, the powder-coat panel, screen glass, glow and blur |
+| `affine_ui/components` | `Knob`, `KeyButton`, `SelectorKeys`, `DisplayLabel`, `Faceplate`, and the silkscreen and screen helpers |
+| `affine_ui/theme` | Palette, metrics, fonts, `LookAndFeel` |
+| `fonts` | Barlow Condensed, Share Tech Mono and Michroma (all SIL OFL 1.1; licences in `fonts/licenses`) |
+| `docs/family.png` | Both products side by side |
+| `DESIGN_LANGUAGE.md` | Principles, tokens, anatomy, the screen, component contracts, states |
+
+## Rendering notes
+
+Knobs and the panel are shaded per pixel at the display's physical scale and cached, so
+they stay sharp at 100–200% and on Retina displays. Only the parts that move are redrawn:
+the knob's grip band is re-shaded (about a millisecond at 2x) when its value changes, and
+screens repaint their own bounds. Nothing here runs on the audio thread.
diff --git a/libs/affine-ui/affine_ui/affine_ui.cpp b/libs/affine-ui/affine_ui/affine_ui.cpp
new file mode 100644
index 0000000..d5f7944
--- /dev/null
+++ b/libs/affine-ui/affine_ui/affine_ui.cpp
@@ -0,0 +1,21 @@
+#ifdef AFFINE_UI_H_INCLUDED
+ /* When you add this cpp file to your project, you mustn't include it in a file where you've
+ already included any other headers - just put it inside a file on its own, possibly with your config
+ flags preceding it, but don't include anything else. That also includes avoiding any automatic prefix
+ header files that the compiler may be using.
+ */
+ #error "Incorrect use of JUCE cpp file"
+#endif
+
+#include "affine_ui.h"
+
+#include "render/affine_KnobRenderer.cpp"
+#include "render/affine_PanelRenderer.cpp"
+#include "theme/affine_Fonts.cpp"
+#include "render/affine_Glow.cpp"
+#include "components/affine_Knob.cpp"
+#include "components/affine_Displays.cpp"
+#include "components/affine_Keys.cpp"
+#include "components/affine_Faceplate.cpp"
+#include "components/affine_Screen.cpp"
+#include "theme/affine_LookAndFeel.cpp"
diff --git a/libs/affine-ui/affine_ui/affine_ui.h b/libs/affine-ui/affine_ui/affine_ui.h
new file mode 100644
index 0000000..875d8d9
--- /dev/null
+++ b/libs/affine-ui/affine_ui/affine_ui.h
@@ -0,0 +1,42 @@
+/*******************************************************************************
+ BEGIN_JUCE_MODULE_DECLARATION
+
+ ID: affine_ui
+ vendor: affine
+ version: 3.0.0
+ name: Affine instrument UI
+ description: Shared rendered-instrument design language for Affine plug-ins
+ license: GPL-3.0-or-later
+ minimumCppStandard: 17
+
+ dependencies: juce_gui_basics, juce_audio_processors
+
+ END_JUCE_MODULE_DECLARATION
+*******************************************************************************/
+
+#pragma once
+#define AFFINE_UI_H_INCLUDED
+
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "render/affine_Shading.h"
+#include "render/affine_KnobRenderer.h"
+#include "render/affine_PanelRenderer.h"
+#include "theme/affine_Fonts.h"
+#include "render/affine_Glow.h"
+#include "theme/affine_Theme.h"
+#include "components/affine_Knob.h"
+#include "components/affine_Displays.h"
+#include "components/affine_Keys.h"
+#include "components/affine_Faceplate.h"
+#include "components/affine_Screen.h"
+#include "theme/affine_LookAndFeel.h"
diff --git a/libs/affine-ui/affine_ui/components/affine_Displays.cpp b/libs/affine-ui/affine_ui/components/affine_Displays.cpp
new file mode 100644
index 0000000..f1451c5
--- /dev/null
+++ b/libs/affine-ui/affine_ui/components/affine_Displays.cpp
@@ -0,0 +1,129 @@
+namespace affine
+{
+namespace render
+{
+void lamp (juce::Graphics& g, juce::Point centre, float diameter, juce::Colour colour, float level)
+{
+ level = juce::jlimit (0.0f, 1.0f, level);
+ const auto r = diameter * 0.5f;
+ const auto lens = juce::Rectangle (diameter, diameter).withCentre (centre);
+
+ // Bezel ring pressed into the plate.
+ g.setColour (juce::Colours::black.withAlpha (0.65f));
+ g.fillEllipse (lens.expanded (r * 0.38f));
+ g.setColour (juce::Colours::white.withAlpha (0.10f));
+ g.drawEllipse (lens.expanded (r * 0.38f).translated (0.0f, 0.6f), 0.8f);
+
+ halo (g, centre, r * 4.2f, colour, 0.34f * level);
+
+ const auto unlit = colour.withMultipliedSaturation (0.55f).withMultipliedBrightness (0.20f);
+ const auto body = unlit.interpolatedWith (colour, level);
+ juce::ColourGradient dome (body.interpolatedWith (juce::Colours::white, 0.35f * level + 0.08f), centre.x - r * 0.3f, centre.y - r * 0.4f,
+ body.darker (0.5f - 0.35f * level), centre.x + r * 0.5f, centre.y + r * 0.8f, true);
+ g.setGradientFill (dome);
+ g.fillEllipse (lens);
+
+ // Specular glint on the lens.
+ g.setColour (juce::Colours::white.withAlpha (0.45f + 0.25f * level));
+ g.fillEllipse (juce::Rectangle (r * 0.62f, r * 0.42f).withCentre (centre.translated (-r * 0.28f, -r * 0.40f)));
+}
+
+void glass (juce::Graphics& g, juce::Rectangle area, const Palette& palette, float corner)
+{
+ juce::ColourGradient body (palette.glassTint, area.getX(), area.getY(),
+ palette.glass, area.getX(), area.getBottom(), false);
+ g.setGradientFill (body);
+ g.fillRoundedRectangle (area, corner);
+ recess (g, area, corner, 0.45f);
+
+ // A faint reflection across the upper half of the glass.
+ juce::Graphics::ScopedSaveState state (g);
+ juce::Path clip;
+ clip.addRoundedRectangle (area, corner);
+ g.reduceClipRegion (clip);
+ juce::ColourGradient sheen (juce::Colours::white.withAlpha (0.045f), area.getX(), area.getY(),
+ juce::Colours::white.withAlpha (0.0f), area.getX(), area.getCentreY(), false);
+ g.setGradientFill (sheen);
+ g.fillRect (area.withHeight (area.getHeight() * 0.5f));
+}
+} // namespace render
+
+//==============================================================================
+DisplayLabel::DisplayLabel() : font (fonts::readout (14.0f, 0.06f))
+{
+ setBorderSize (juce::BorderSize (0));
+ setInterceptsMouseClicks (true, false);
+}
+
+void DisplayLabel::setTheme (const Theme& t)
+{
+ theme = t;
+ if (emission.isTransparent())
+ emission = theme.palette.accent;
+ repaint();
+}
+
+void DisplayLabel::setEmission (juce::Colour colour, float level)
+{
+ if (colour == emission && juce::approximatelyEqual (level, lampLevel))
+ return;
+ emission = colour;
+ lampLevel = level;
+ repaint();
+}
+
+void DisplayLabel::setLampColour (juce::Colour colour)
+{
+ lampColour = colour;
+ repaint();
+}
+
+void DisplayLabel::setShowsLamp (bool shouldShow)
+{
+ showsLamp = shouldShow;
+ repaint();
+}
+
+void DisplayLabel::setGlassVisible (bool shouldShow)
+{
+ glassVisible = shouldShow;
+ repaint();
+}
+
+void DisplayLabel::setDisplayFont (const juce::Font& f)
+{
+ font = f;
+ repaint();
+}
+
+void DisplayLabel::paint (juce::Graphics& g)
+{
+ auto area = getLocalBounds().toFloat();
+ if (glassVisible)
+ render::glass (g, area, theme.palette);
+ area = area.reduced (glassVisible ? 9.0f : 0.0f, 0.0f);
+
+ const auto text = getText().toUpperCase();
+ if (text.isEmpty() && ! glassVisible)
+ return;
+
+ const auto justification = getJustificationType();
+ if (showsLamp)
+ {
+ // A right-aligned legend keeps its lamp immediately to the left of the text.
+ auto lampSlot = area.removeFromLeft (12.0f);
+ if (justification.testFlags (juce::Justification::right))
+ {
+ juce::GlyphArrangement glyphs;
+ glyphs.addLineOfText (font, text, 0.0f, 0.0f);
+ const auto textWidth = glyphs.getBoundingBox (0, -1, true).getWidth();
+ lampSlot = lampSlot.withX (area.getRight() - textWidth - 19.0f);
+ }
+ render::lamp (g, lampSlot.getCentre(), 6.0f, lampColour.isTransparent() ? emission : lampColour, lampLevel);
+ area.removeFromLeft (7.0f);
+ }
+
+ glow.draw (g, text, font, area, justification, emission.withMultipliedAlpha (isEnabled() ? 1.0f : 0.4f),
+ 2.4f, 0.55f + 0.45f * lampLevel);
+}
+} // namespace affine
diff --git a/libs/affine-ui/affine_ui/components/affine_Displays.h b/libs/affine-ui/affine_ui/components/affine_Displays.h
new file mode 100644
index 0000000..90eadfa
--- /dev/null
+++ b/libs/affine-ui/affine_ui/components/affine_Displays.h
@@ -0,0 +1,41 @@
+#pragma once
+
+namespace affine
+{
+namespace render
+{
+/** A panel LED behind a domed lens. `level` 0 is dark, 1 is fully lit. */
+void lamp (juce::Graphics&, juce::Point centre, float diameter, juce::Colour colour, float level);
+
+/** A glass display window cut into the panel. */
+void glass (juce::Graphics&, juce::Rectangle area, const Palette&, float corner = metrics::displayCorner);
+} // namespace render
+
+/**
+ A label that presents itself as a small glowing display with an optional
+ status lamp. The text stays the accessible text; the display shows it in capitals.
+*/
+class DisplayLabel : public juce::Label
+{
+public:
+ DisplayLabel();
+
+ void setTheme (const Theme&);
+ void setEmission (juce::Colour colour, float lampLevel);
+ /** Lamp colour when it should differ from the text's emission. */
+ void setLampColour (juce::Colour);
+ void setShowsLamp (bool);
+ void setGlassVisible (bool);
+ void setDisplayFont (const juce::Font&);
+
+ void paint (juce::Graphics&) override;
+
+private:
+ Theme theme;
+ juce::Colour emission, lampColour;
+ juce::Font font;
+ float lampLevel = 0.0f;
+ bool showsLamp = true, glassVisible = true;
+ render::GlowText glow;
+};
+} // namespace affine
diff --git a/libs/affine-ui/affine_ui/components/affine_Faceplate.cpp b/libs/affine-ui/affine_ui/components/affine_Faceplate.cpp
new file mode 100644
index 0000000..d2c7548
--- /dev/null
+++ b/libs/affine-ui/affine_ui/components/affine_Faceplate.cpp
@@ -0,0 +1,79 @@
+namespace affine
+{
+void Faceplate::paint (juce::Graphics& g, juce::Rectangle bounds, const Theme& theme, const Printer& print)
+{
+ const auto scale = juce::jmax (0.5f, g.getInternalContext().getPhysicalPixelScaleFactor());
+ if (! cache.isValid() || bounds != cachedBounds || ! juce::approximatelyEqual (scale, cachedScale))
+ {
+ cachedBounds = bounds;
+ cachedScale = scale;
+ cache = render::faceplate (bounds, scale, theme.panel);
+ juce::Graphics layer (cache);
+ layer.addTransform (juce::AffineTransform::scale (scale));
+ layer.addTransform (juce::AffineTransform::translation (static_cast (-bounds.getX()), static_cast (-bounds.getY())));
+
+ // Machined edge of the panel.
+ const auto edge = bounds.toFloat();
+ layer.setColour (juce::Colours::white.withAlpha (0.10f));
+ layer.fillRect (edge.withHeight (1.0f));
+ layer.setColour (juce::Colours::black.withAlpha (0.45f));
+ layer.fillRect (edge.withTop (edge.getBottom() - 1.0f));
+
+ if (print != nullptr)
+ print (layer);
+ }
+
+ g.drawImageTransformed (cache, juce::AffineTransform::scale (1.0f / scale)
+ .translated (static_cast (bounds.getX()), static_cast (bounds.getY())));
+}
+
+namespace silkscreen
+{
+void wordmark (juce::Graphics& g, const juce::String& product, const juce::String& descriptor,
+ juce::Point topLeft, const Palette& palette)
+{
+ g.setColour (palette.silkscreen);
+ g.setFont (fonts::label (30.0f, 0.45f));
+ g.drawText (product.toUpperCase(), juce::Rectangle (topLeft.x, topLeft.y, 600.0f, 36.0f),
+ juce::Justification::centredLeft, false);
+ g.setColour (palette.silkscreenDim);
+ g.setFont (fonts::label (11.5f, 0.3f));
+ g.drawText (descriptor.toUpperCase(), juce::Rectangle (topLeft.x + 2.0f, topLeft.y + 36.0f, 600.0f, 14.0f),
+ juce::Justification::centredLeft, false);
+}
+
+void makersMark (juce::Graphics& g, juce::Point baselineLeft, const Palette& palette)
+{
+ const auto colour = palette.silkscreenDim.withMultipliedAlpha (0.85f);
+ // A unit square under a horizontal shear.
+ juce::Path mark;
+ const auto size = 9.0f, shear = 3.5f;
+ const auto x = baselineLeft.x, y = baselineLeft.y;
+ mark.startNewSubPath (x + shear, y - size);
+ mark.lineTo (x + shear + size, y - size);
+ mark.lineTo (x + size, y);
+ mark.lineTo (x, y);
+ mark.closeSubPath();
+ g.setColour (colour);
+ g.strokePath (mark, juce::PathStrokeType (1.2f, juce::PathStrokeType::mitered));
+ g.setFont (fonts::wordmark (9.5f, 0.32f));
+ g.drawText ("affine", juce::Rectangle (x + size + shear + 6.0f, y - size - 2.0f, 120.0f, size + 4.0f),
+ juce::Justification::centredLeft, false);
+}
+
+void section (juce::Graphics& g, const juce::String& title, juce::Rectangle rule, const Palette& palette)
+{
+ const auto font = fonts::label (11.5f, 0.26f);
+ const auto text = title.toUpperCase();
+ juce::GlyphArrangement glyphs;
+ glyphs.addLineOfText (font, text, 0.0f, 0.0f);
+ const auto textWidth = glyphs.getBoundingBox (0, -1, true).getWidth();
+ g.setColour (palette.silkscreenDim);
+ g.setFont (font);
+ g.drawText (text, rule.withWidth (textWidth + 8.0f).withHeight (14.0f).translated (0.0f, -7.0f),
+ juce::Justification::centredLeft, false);
+ g.setColour (juce::Colours::white.withAlpha (0.08f));
+ g.fillRect (rule.withTrimmedLeft (textWidth + 14.0f).withHeight (1.0f));
+}
+} // namespace silkscreen
+} // namespace affine
diff --git a/libs/affine-ui/affine_ui/components/affine_Faceplate.h b/libs/affine-ui/affine_ui/components/affine_Faceplate.h
new file mode 100644
index 0000000..1f84ddd
--- /dev/null
+++ b/libs/affine-ui/affine_ui/components/affine_Faceplate.h
@@ -0,0 +1,39 @@
+#pragma once
+
+namespace affine
+{
+/**
+ The static layer of an editor: the powder-coated panel and everything
+ printed on it. Rendered once per size and physical scale, so editors
+ repaint dynamic parts without touching the texture or the typography.
+*/
+class Faceplate
+{
+public:
+ using Printer = std::function;
+
+ /** Paints the panel for `bounds` and calls `print` (in logical coordinates) when rebuilding. */
+ void paint (juce::Graphics&, juce::Rectangle bounds, const Theme&, const Printer& print);
+
+ /** Forces the next paint to rebuild, e.g. after printed text changed. */
+ void invalidate() noexcept { cache = {}; }
+
+private:
+ juce::Image cache;
+ juce::Rectangle cachedBounds;
+ float cachedScale = 0.0f;
+};
+
+namespace silkscreen
+{
+/** The product name, wide-tracked, with its descriptor underneath. */
+void wordmark (juce::Graphics&, const juce::String& product, const juce::String& descriptor,
+ juce::Point topLeft, const Palette&);
+
+/** The maker's mark: a sheared square and the name, as an affine transform of a square. */
+void makersMark (juce::Graphics&, juce::Point baselineLeft, const Palette&);
+
+/** A section title over a group of controls, with a hairline running from it to the end of `rule`. */
+void section (juce::Graphics&, const juce::String& title, juce::Rectangle rule, const Palette&);
+} // namespace silkscreen
+} // namespace affine
diff --git a/libs/affine-ui/affine_ui/components/affine_Keys.cpp b/libs/affine-ui/affine_ui/components/affine_Keys.cpp
new file mode 100644
index 0000000..fb5dcb2
--- /dev/null
+++ b/libs/affine-ui/affine_ui/components/affine_Keys.cpp
@@ -0,0 +1,286 @@
+namespace affine
+{
+KeyButton::KeyButton (const juce::String& name) : juce::Button (name)
+{
+ setWantsKeyboardFocus (true);
+ setMouseClickGrabsKeyboardFocus (true);
+ lampColour = theme.palette.attention;
+}
+
+void KeyButton::setTheme (const Theme& t)
+{
+ theme = t;
+ repaint();
+}
+
+void KeyButton::setLampColour (juce::Colour c)
+{
+ lampColour = c;
+ repaint();
+}
+
+void KeyButton::setLegend (const juce::String& lineOne, const juce::String& lineTwo)
+{
+ legendOne = lineOne;
+ legendTwo = lineTwo;
+ repaint();
+}
+
+void KeyButton::setGlyph (const juce::Path& p)
+{
+ glyph = p;
+ repaint();
+}
+
+void KeyButton::setKeySize (float width, float height)
+{
+ keyWidth = width;
+ keyHeight = height;
+ repaint();
+}
+
+juce::Rectangle KeyButton::getKeyBounds() const
+{
+ auto bounds = getLocalBounds().toFloat();
+ const auto legend = legendTwo.isNotEmpty() ? 30.0f : (legendOne.isNotEmpty() ? 18.0f : 0.0f);
+ bounds.removeFromBottom (legend);
+ const auto width = juce::jmin (bounds.getWidth() - 8.0f, keyWidth);
+ const auto height = juce::jmin (bounds.getHeight() - 8.0f, keyHeight);
+ return juce::Rectangle (width, height).withCentre (bounds.getCentre());
+}
+
+bool KeyButton::keyPressed (const juce::KeyPress& key)
+{
+ const auto code = key.getKeyCode();
+ if (onArrow != nullptr && isEnabled())
+ {
+ if (code == juce::KeyPress::leftKey || code == juce::KeyPress::upKey)
+ {
+ onArrow (-1);
+ return true;
+ }
+ if (code == juce::KeyPress::rightKey || code == juce::KeyPress::downKey)
+ {
+ onArrow (1);
+ return true;
+ }
+ }
+ if (isEnabled() && (key.isKeyCode (juce::KeyPress::spaceKey) || key.isKeyCode (juce::KeyPress::returnKey)))
+ {
+ if (getClickingTogglesState())
+ setToggleState (! getToggleState(), juce::sendNotificationSync);
+ else
+ triggerClick();
+ return true;
+ }
+ return juce::Button::keyPressed (key);
+}
+
+void KeyButton::paintGlyph (juce::Graphics& g, juce::Rectangle area, juce::Colour colour, float width)
+{
+ if (glyph.isEmpty())
+ return;
+ auto shape = glyph;
+ shape.applyTransform (shape.getTransformToScaleToFit (area, false));
+ g.setColour (colour);
+ g.strokePath (shape, juce::PathStrokeType (width, juce::PathStrokeType::curved, juce::PathStrokeType::rounded));
+}
+
+void KeyButton::paintLegend (juce::Graphics& g)
+{
+ const auto& palette = theme.palette;
+ auto legend = getLocalBounds().toFloat();
+ legend = legend.removeFromBottom (legendTwo.isNotEmpty() ? 30.0f : 18.0f);
+ g.setColour (palette.silkscreen);
+ g.setFont (fonts::label (12.0f, 0.16f));
+ g.drawText (legendOne.toUpperCase(), legend.removeFromTop (15.0f), juce::Justification::centred, false);
+ if (legendTwo.isNotEmpty())
+ {
+ g.setColour (palette.silkscreenDim);
+ g.setFont (fonts::label (10.0f, 0.18f));
+ g.drawText (legendTwo.toUpperCase(), legend.removeFromTop (13.0f), juce::Justification::centred, false);
+ }
+}
+
+void KeyButton::paintButton (juce::Graphics& g, bool highlighted, bool down)
+{
+ const auto& palette = theme.palette;
+ const auto key = getKeyBounds();
+ const auto engaged = getToggleState();
+ const auto cap = key.reduced (down ? 1.0f : 0.0f);
+
+ render::softShadow (g, cap, 6.0f, 6.0f, { 0.0f, 2.5f }, 0.65f);
+ juce::ColourGradient rubber (juce::Colour (0xff34363b), cap.getX(), cap.getY(),
+ juce::Colour (0xff1c1d20), cap.getX(), cap.getBottom(), false);
+ g.setGradientFill (rubber);
+ g.fillRoundedRectangle (cap, 6.0f);
+ g.setColour (juce::Colours::white.withAlpha (highlighted ? 0.14f : 0.08f));
+ g.drawRoundedRectangle (cap.reduced (0.5f), 6.0f, 1.0f);
+
+ // LED strip along the top edge of the key.
+ const auto strip = juce::Rectangle (cap.getWidth() * 0.5f, 3.0f).withCentre ({ cap.getCentreX(), cap.getY() + 6.0f });
+ g.setColour (engaged ? lampColour : lampColour.withMultipliedSaturation (0.4f).withMultipliedBrightness (0.25f));
+ g.fillRoundedRectangle (strip, 1.5f);
+ if (engaged)
+ render::halo (g, strip.getCentre(), strip.getWidth() * 0.9f, lampColour, 0.5f);
+
+ paintGlyph (g, cap.withTrimmedTop (12.0f).reduced (cap.getWidth() * 0.26f, cap.getHeight() * 0.14f),
+ juce::Colour (0xffeceef0).withAlpha (engaged ? 0.95f : 0.65f), 1.4f);
+
+ if (hasKeyboardFocus (true))
+ {
+ g.setColour (palette.accent.withAlpha (0.9f));
+ g.drawRoundedRectangle (cap.expanded (3.0f), 8.0f, 1.5f);
+ }
+ paintLegend (g);
+}
+
+//==============================================================================
+SelectorKeys::SelectorKeys (juce::AudioProcessorValueTreeState& state, const juce::String& parameterID)
+ : parameter (*state.getParameter (parameterID)),
+ attachment (parameter, [this] (float value) { select (juce::roundToInt (value)); }, state.undoManager)
+{
+ setComponentID (parameterID);
+ setTitle (parameter.getName (64));
+ setName (parameter.getName (64));
+ setFocusContainerType (juce::Component::FocusContainerType::focusContainer);
+
+ const auto choices = parameter.getAllValueStrings();
+ const auto groupId = 0x5e1ec700 + parameter.getParameterIndex();
+ for (int i = 0; i < choices.size(); ++i)
+ {
+ auto* key = keys.add (std::make_unique (choices[i]));
+ key->setTitle (choices[i]);
+ key->setDescription (parameter.getName (64));
+ key->setRadioGroupId (groupId);
+ key->setClickingTogglesState (false);
+ key->setLegend (choices[i]);
+ key->setKeySize (40.0f, 30.0f);
+ key->onClick = [this, i] { attachment.setValueAsCompleteGesture (static_cast (i)); };
+ key->onArrow = [this, i] (int direction)
+ {
+ const auto next = juce::jlimit (0, keys.size() - 1, i + direction);
+ if (next != i)
+ {
+ attachment.setValueAsCompleteGesture (static_cast (next));
+ keys[next]->grabKeyboardFocus();
+ }
+ };
+ addAndMakeVisible (key);
+ }
+ attachment.sendInitialUpdate();
+}
+
+SelectorKeys::~SelectorKeys() = default;
+
+void SelectorKeys::setTheme (const Theme& t)
+{
+ for (auto* key : keys)
+ {
+ key->setTheme (t);
+ key->setLampColour (t.palette.accent);
+ }
+}
+
+void SelectorKeys::setColumns (int count)
+{
+ columns = juce::jmax (0, count);
+ resized();
+}
+
+void SelectorKeys::setLegends (const juce::StringArray& legends)
+{
+ for (int i = 0; i < keys.size() && i < legends.size(); ++i)
+ keys[i]->setLegend (legends[i]);
+}
+
+void SelectorKeys::setGlyphs (const std::vector& paths)
+{
+ for (int i = 0; i < keys.size() && i < static_cast (paths.size()); ++i)
+ keys[i]->setGlyph (paths[static_cast (i)]);
+}
+
+void SelectorKeys::setKeySize (float width, float height)
+{
+ for (auto* key : keys)
+ key->setKeySize (width, height);
+}
+
+void SelectorKeys::select (int index)
+{
+ index = juce::jlimit (0, keys.size() - 1, index);
+ for (int i = 0; i < keys.size(); ++i)
+ keys[i]->setToggleState (i == index, juce::dontSendNotification);
+ if (index != selected)
+ {
+ selected = index;
+ if (onChange != nullptr)
+ onChange (index);
+ }
+}
+
+std::unique_ptr SelectorKeys::createAccessibilityHandler()
+{
+ return std::make_unique (*this, juce::AccessibilityRole::group);
+}
+
+void SelectorKeys::resized()
+{
+ const auto bounds = getLocalBounds();
+ const auto cols = columns > 0 ? juce::jmin (columns, keys.size()) : keys.size();
+ const auto rows = (keys.size() + cols - 1) / juce::jmax (1, cols);
+ const auto width = bounds.getWidth() / juce::jmax (1, cols);
+ const auto height = bounds.getHeight() / juce::jmax (1, rows);
+ for (int i = 0; i < keys.size(); ++i)
+ keys[i]->setBounds (bounds.getX() + (i % cols) * width, bounds.getY() + (i / cols) * height, width, height);
+}
+
+//==============================================================================
+namespace glyphs
+{
+juce::Path sine()
+{
+ juce::Path p;
+ for (int i = 0; i <= 48; ++i)
+ {
+ const auto x = static_cast (i) / 48.0f;
+ const auto y = 0.5f - 0.5f * std::sin (x * juce::MathConstants::twoPi);
+ if (i == 0) p.startNewSubPath (x, y); else p.lineTo (x, y);
+ }
+ return p;
+}
+
+juce::Path triangle()
+{
+ juce::Path p;
+ p.startNewSubPath (0.0f, 0.5f);
+ p.lineTo (0.25f, 0.0f);
+ p.lineTo (0.75f, 1.0f);
+ p.lineTo (1.0f, 0.5f);
+ return p;
+}
+
+juce::Path square()
+{
+ juce::Path p;
+ p.startNewSubPath (0.0f, 1.0f);
+ p.lineTo (0.0f, 0.0f);
+ p.lineTo (0.5f, 0.0f);
+ p.lineTo (0.5f, 1.0f);
+ p.lineTo (1.0f, 1.0f);
+ p.lineTo (1.0f, 0.0f);
+ return p;
+}
+
+juce::Path saw()
+{
+ juce::Path p;
+ p.startNewSubPath (0.0f, 1.0f);
+ p.lineTo (0.5f, 0.0f);
+ p.lineTo (0.5f, 1.0f);
+ p.lineTo (1.0f, 0.0f);
+ p.lineTo (1.0f, 1.0f);
+ return p;
+}
+} // namespace glyphs
+} // namespace affine
diff --git a/libs/affine-ui/affine_ui/components/affine_Keys.h b/libs/affine-ui/affine_ui/components/affine_Keys.h
new file mode 100644
index 0000000..f0b51b9
--- /dev/null
+++ b/libs/affine-ui/affine_ui/components/affine_Keys.h
@@ -0,0 +1,85 @@
+#pragma once
+
+namespace affine
+{
+/**
+ A latching rubber key with an LED strip that lights while it is engaged,
+ and a printed legend below it. Space and Return operate it.
+*/
+class KeyButton : public juce::Button
+{
+public:
+ explicit KeyButton (const juce::String& name);
+
+ void setTheme (const Theme&);
+ void setLampColour (juce::Colour);
+ void setLegend (const juce::String& lineOne, const juce::String& lineTwo = {});
+ /** A glyph printed on the key cap, fitted inside its lower half. */
+ void setGlyph (const juce::Path&);
+ /** Maximum key cap size; the key is centred above the legend. */
+ void setKeySize (float width, float height);
+ juce::Rectangle getKeyBounds() const;
+
+ /** Arrow keys, for moving within a group of keys: -1 or +1. */
+ std::function onArrow;
+
+ bool keyPressed (const juce::KeyPress&) override;
+ void paintButton (juce::Graphics&, bool highlighted, bool down) override;
+
+private:
+ void paintGlyph (juce::Graphics&, juce::Rectangle cap, juce::Colour, float width);
+ void paintLegend (juce::Graphics&);
+
+ Theme theme;
+ juce::Colour lampColour;
+ juce::String legendOne, legendTwo;
+ juce::Path glyph;
+ float keyWidth = 58.0f, keyHeight = 40.0f;
+};
+
+/**
+ Keys bound to one choice parameter: one key per choice, the selected key
+ lit. Every press is one complete host gesture.
+*/
+class SelectorKeys : public juce::Component
+{
+public:
+ SelectorKeys (juce::AudioProcessorValueTreeState&, const juce::String& parameterID);
+ ~SelectorKeys() override;
+
+ juce::RangedAudioParameter& parameter;
+
+ void setTheme (const Theme&);
+ void setLegends (const juce::StringArray&);
+ void setGlyphs (const std::vector&);
+ void setKeySize (float width, float height);
+ /** Keys are laid out left to right, wrapping after this many columns. */
+ void setColumns (int);
+ int getSelectedIndex() const noexcept { return selected; }
+ KeyButton* getKey (int index) const { return keys[index]; }
+ int getNumKeys() const noexcept { return keys.size(); }
+
+ std::function onChange;
+
+ void resized() override;
+ std::unique_ptr createAccessibilityHandler() override;
+
+private:
+ void select (int index);
+
+ juce::OwnedArray keys;
+ int selected = -1, columns = 0;
+ juce::ParameterAttachment attachment;
+
+ JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (SelectorKeys)
+};
+
+/** Unit-square glyphs for common oscillator shapes, for stroking. */
+namespace glyphs
+{
+juce::Path sine();
+juce::Path triangle();
+juce::Path square();
+juce::Path saw();
+} // namespace glyphs
+} // namespace affine
diff --git a/libs/affine-ui/affine_ui/components/affine_Knob.cpp b/libs/affine-ui/affine_ui/components/affine_Knob.cpp
new file mode 100644
index 0000000..6a14f53
--- /dev/null
+++ b/libs/affine-ui/affine_ui/components/affine_Knob.cpp
@@ -0,0 +1,425 @@
+namespace affine
+{
+namespace
+{
+constexpr float labelHeight = 16.0f, scaleMargin = 19.0f, readoutGap = 11.0f, readoutHeight = 20.0f;
+
+void strokeRadial (juce::Graphics& g, juce::Point centre, float angle, float from, float to, float width)
+{
+ juce::Path p;
+ p.startNewSubPath (centre.getPointOnCircumference (from, angle));
+ p.lineTo (centre.getPointOnCircumference (to, angle));
+ g.strokePath (p, juce::PathStrokeType (width, juce::PathStrokeType::curved, juce::PathStrokeType::rounded));
+}
+} // namespace
+
+Knob::Knob (juce::AudioProcessorValueTreeState& state, const juce::String& parameterID, const juce::String& help)
+ : parameter (*state.getParameter (parameterID)), attachment (state, parameterID, *this)
+{
+ setComponentID (parameterID);
+ setName (parameter.getName (64));
+ setTitle (getName());
+ label = getName();
+ setDescription (help);
+ setTooltip ((help.isNotEmpty() ? help + " " : juce::String())
+ + "Double-click or Return to type. Shift-drag for fine adjustment. "
+ "Alt/Option-click to reset; right-click for the menu.");
+ setSliderStyle (juce::Slider::RotaryHorizontalVerticalDrag);
+ setTextBoxStyle (juce::Slider::NoTextBox, false, 0, 0);
+ if (parameter.getLabel().isNotEmpty())
+ setTextValueSuffix (" " + parameter.getLabel());
+ setRotaryParameters (juce::MathConstants::pi * 1.25f, juce::MathConstants::pi * 2.75f, true);
+ setScrollWheelEnabled (false);
+ setWantsKeyboardFocus (true);
+ setMouseClickGrabsKeyboardFocus (true);
+ setDoubleClickReturnValue (false, 0.0);
+ setOpaque (false);
+
+ addChildComponent (entry);
+ entry.setName (getName() + " exact value");
+ entry.setJustification (juce::Justification::centred);
+ entry.setFont (fonts::readout (15.0f));
+ entry.setIndents (4, 2);
+ entry.onReturnKey = [this] { finishEntry (true); };
+ entry.onEscapeKey = [this] { finishEntry (false); };
+ entry.onFocusLost = [this] { finishEntry (false); };
+ setTheme ({});
+}
+
+Knob::~Knob() { cancelInteraction(); }
+
+void Knob::setTheme (const Theme& newTheme)
+{
+ theme = newTheme;
+ renderer = KnobRenderer (theme.knob);
+ const auto& p = theme.palette;
+ entry.setColour (juce::TextEditor::backgroundColourId, p.glass);
+ entry.setColour (juce::TextEditor::textColourId, p.accent.interpolatedWith (juce::Colours::white, 0.3f));
+ entry.setColour (juce::TextEditor::highlightColourId, p.accent.withAlpha (0.35f));
+ entry.setColour (juce::TextEditor::highlightedTextColourId, juce::Colours::white);
+ entry.setColour (juce::TextEditor::outlineColourId, p.accent.withAlpha (0.8f));
+ entry.setColour (juce::TextEditor::focusedOutlineColourId, p.accent);
+ entry.setColour (juce::CaretComponent::caretColourId, p.accent);
+ repaint();
+}
+
+void Knob::setDiameter (float d)
+{
+ diameter = juce::jmax (24.0f, d);
+ resized();
+ repaint();
+}
+
+void Knob::setLabel (const juce::String& text)
+{
+ label = text;
+ repaint();
+}
+
+void Knob::setScale (std::vector plainValues, std::function formatter)
+{
+ scaleValues = std::move (plainValues);
+ scaleFormatter = std::move (formatter);
+ repaint();
+}
+
+void Knob::setInactive (bool shouldBeInactive)
+{
+ if (inactive == shouldBeInactive)
+ return;
+ inactive = shouldBeInactive;
+ repaint();
+}
+
+void Knob::setShowsActivityLamp (bool shouldShow)
+{
+ activityLamp = shouldShow;
+ repaint();
+}
+
+juce::Point Knob::getPreferredSize() const
+{
+ return { juce::roundToInt (diameter + 76.0f),
+ juce::roundToInt (labelHeight + scaleMargin + diameter + readoutGap + readoutHeight + 2.0f) };
+}
+
+juce::Rectangle Knob::getBoundsForCentre (juce::Point knobCentre) const
+{
+ const auto size = getPreferredSize();
+ const auto axis = juce::roundToInt (labelHeight + scaleMargin + diameter * 0.5f);
+ return { knobCentre.x - size.x / 2, knobCentre.y - axis, size.x, size.y };
+}
+
+Knob::Geometry Knob::getGeometry() const
+{
+ const auto bounds = getLocalBounds().toFloat();
+ Geometry geometry;
+ geometry.radius = diameter * 0.5f;
+ geometry.label = bounds.withHeight (labelHeight);
+ geometry.centre = { bounds.getCentreX(), labelHeight + scaleMargin + geometry.radius };
+ const auto readoutWidth = juce::jmin (bounds.getWidth() - 2.0f, juce::jmax (78.0f, diameter + 14.0f));
+ geometry.readout = juce::Rectangle (readoutWidth, readoutHeight)
+ .withCentre ({ bounds.getCentreX(),
+ geometry.centre.y + geometry.radius + readoutGap + readoutHeight * 0.5f });
+ return geometry;
+}
+
+juce::Point Knob::getKnobCentre() const { return getGeometry().centre; }
+
+void Knob::resized()
+{
+ juce::Slider::resized();
+ entry.setBounds (getGeometry().readout.getSmallestIntegerContainer());
+}
+
+void Knob::paint (juce::Graphics& g)
+{
+ const auto geometry = getGeometry();
+ const auto& palette = theme.palette;
+ const auto enabled = isEnabled();
+ const auto live = enabled && ! inactive;
+ const auto scale = juce::jmax (0.5f, g.getInternalContext().getPhysicalPixelScaleFactor());
+
+ // Label, with an optional activity lamp. Hovering lifts the label so the target is clear.
+ {
+ g.setFont (fonts::label (12.5f, 0.16f));
+ const auto text = label.toUpperCase();
+ const auto hover = enabled && isMouseOverOrDragging (true);
+ g.setColour ((hover ? juce::Colours::white : palette.silkscreen).withMultipliedAlpha (live ? 1.0f : 0.62f));
+ g.drawText (text, geometry.label, juce::Justification::centred, false);
+
+ if (activityLamp)
+ {
+ juce::GlyphArrangement glyphs;
+ glyphs.addLineOfText (g.getCurrentFont(), text, 0.0f, 0.0f);
+ const auto textWidth = glyphs.getBoundingBox (0, -1, true).getWidth();
+ render::lamp (g, { geometry.label.getCentreX() - textWidth * 0.5f - 9.0f, geometry.label.getCentreY() - 0.5f },
+ 4.5f, palette.accent, live ? 1.0f : 0.0f);
+ }
+ }
+
+ paintScale (g, geometry);
+
+ // The knob itself.
+ const auto physical = juce::roundToInt (diameter * scale);
+ renderer.prepare (physical);
+ const auto inverse = 1.0f / scale;
+ const auto size = static_cast (physical) * inverse;
+ const auto origin = geometry.centre - juce::Point (size, size) * 0.5f;
+ const auto margin = static_cast (renderer.getShadowMargin()) * inverse;
+ const auto rotary = getRotaryParameters();
+ const auto proportion = static_cast (valueToProportionOfLength (getValue()));
+ const auto angle = rotary.startAngleRadians + proportion * (rotary.endAngleRadians - rotary.startAngleRadians);
+ const auto place = [&] (float x, float y) { return juce::AffineTransform::scale (inverse).translated (x, y); };
+
+ g.drawImageTransformed (renderer.getShadow(), place (origin.x - margin, origin.y - margin));
+ g.drawImageTransformed (renderer.getBody(), place (origin.x, origin.y));
+ g.drawImageTransformed (renderer.getGrip (angle), place (origin.x, origin.y));
+
+ const auto r = geometry.radius;
+ const auto& finish = theme.knob;
+ const auto pointerEnd = r * (finish.capRatio - 0.07f);
+ g.setColour (juce::Colours::black.withAlpha (0.55f));
+ strokeRadial (g, geometry.centre.translated (0.0f, 0.45f), angle, r * 0.15f, pointerEnd, r * 0.070f + 0.8f);
+ g.setColour (finish.pointer);
+ strokeRadial (g, geometry.centre, angle, r * 0.15f, pointerEnd, r * 0.052f);
+ g.setColour (finish.index);
+ strokeRadial (g, geometry.centre, angle, r * (finish.gripRatio + 0.05f), r * 0.955f, juce::jmax (1.2f, r * 0.05f));
+
+ if (! enabled)
+ {
+ g.setColour (theme.panel.base.withAlpha (0.55f));
+ g.fillEllipse (juce::Rectangle (r * 2.0f, r * 2.0f).withCentre (geometry.centre).expanded (1.0f));
+ }
+
+ if (hasKeyboardFocus (true))
+ {
+ // An illuminated ring at the foot of the skirt, inside the printed scale.
+ const auto ring = juce::Rectangle (r * 2.0f, r * 2.0f).withCentre (geometry.centre).expanded (1.6f);
+ g.setColour (palette.accent.withAlpha (0.25f));
+ g.drawEllipse (ring.expanded (0.8f), 2.6f);
+ g.setColour (palette.accent);
+ g.drawEllipse (ring, 1.3f);
+ }
+
+ paintReadout (g, geometry);
+}
+
+void Knob::paintScale (juce::Graphics& g, const Geometry& geometry)
+{
+ const auto& palette = theme.palette;
+ const auto rotary = getRotaryParameters();
+ const auto r = geometry.radius;
+ const auto enabled = isEnabled();
+ const auto live = enabled && ! inactive;
+ const auto angleFor = [&] (float proportion)
+ {
+ return rotary.startAngleRadians + proportion * (rotary.endAngleRadians - rotary.startAngleRadians);
+ };
+
+ // LEDs light from the start of the sweep up to the value; the last one fades in.
+ constexpr int count = 23;
+ const auto proportion = static_cast (valueToProportionOfLength (getValue()));
+ const auto colour = inactive ? palette.accent.withMultipliedSaturation (0.2f).withMultipliedBrightness (0.7f) : palette.accent;
+ for (int i = 0; i < count; ++i)
+ {
+ const auto t = static_cast (i) / static_cast (count - 1);
+ const auto at = geometry.centre.getPointOnCircumference (r + 7.5f, angleFor (t));
+ const auto lit = enabled ? juce::jlimit (0.0f, 1.0f, (proportion - t) * static_cast (count - 1) + 1.0f) : 0.0f;
+ g.setColour (juce::Colours::black.withAlpha (0.7f));
+ g.fillEllipse (juce::Rectangle (4.4f, 4.4f).withCentre (at));
+ if (lit > 0.0f)
+ {
+ if (live)
+ render::halo (g, at, 6.5f, colour, 0.5f * lit);
+ g.setColour (colour.withAlpha (live ? lit : lit * 0.55f));
+ }
+ else
+ {
+ g.setColour (colour.withMultipliedSaturation (0.3f).withMultipliedBrightness (0.18f));
+ }
+ g.fillEllipse (juce::Rectangle (3.0f, 3.0f).withCentre (at));
+ }
+
+ g.setFont (fonts::label (10.0f, 0.02f));
+ g.setColour (palette.silkscreenDim.withMultipliedAlpha (enabled ? 1.0f : 0.5f));
+ for (auto value : scaleValues)
+ {
+ const auto text = scaleFormatter ? scaleFormatter (value) : juce::String (value);
+ const auto at = geometry.centre.getPointOnCircumference (r + 17.0f, angleFor (static_cast (valueToProportionOfLength (value))));
+ g.drawText (text, juce::Rectangle (34.0f, 12.0f).withCentre (at), juce::Justification::centred, false);
+ }
+}
+
+void Knob::paintReadout (juce::Graphics& g, const Geometry& geometry)
+{
+ const auto& palette = theme.palette;
+ const auto area = geometry.readout;
+ const auto live = isEnabled() && ! inactive;
+ const auto text = getTextFromValue (getValue());
+
+ g.setColour (palette.glass);
+ g.fillRoundedRectangle (area, metrics::displayCorner);
+ render::recess (g, area, metrics::displayCorner, 0.45f);
+
+ if (entry.isVisible())
+ return;
+
+ auto colour = palette.accent;
+ if (! isEnabled())
+ colour = palette.silkscreenDim.withMultipliedBrightness (0.45f);
+ else if (inactive)
+ colour = palette.accent.withMultipliedSaturation (0.25f).withMultipliedBrightness (0.62f);
+
+ valueGlow.draw (g, text, fonts::readout (14.5f, 0.02f), area.reduced (4.0f, 0.0f),
+ juce::Justification::centred, colour, 2.2f, live ? 0.85f : 0.35f);
+}
+
+void Knob::setWithGesture (double plainValue)
+{
+ juce::Slider::ScopedDragNotification gesture (*this);
+ setValue (plainValue, juce::sendNotificationSync);
+}
+
+double Knob::defaultValue() const
+{
+ return parameter.convertFrom0to1 (parameter.getDefaultValue());
+}
+
+void Knob::mouseDown (const juce::MouseEvent& e)
+{
+ if (! isEnabled())
+ return;
+ grabKeyboardFocus();
+ if (e.mods.isPopupMenu())
+ {
+ showMenu();
+ return;
+ }
+ if (e.mods.isAltDown())
+ {
+ setWithGesture (defaultValue());
+ return;
+ }
+ lastDrag = e.position;
+ dragProportion = valueToProportionOfLength (getValue());
+ drag = std::make_unique (*this);
+}
+
+void Knob::mouseDrag (const juce::MouseEvent& e)
+{
+ if (drag == nullptr)
+ return;
+ const auto delta = e.position - lastDrag;
+ const double sensitivity = e.mods.isShiftDown() ? 2400.0 : 240.0;
+ // Accumulate sub-step movement so fine drags work on coarse parameter grids.
+ dragProportion = juce::jlimit (0.0, 1.0, dragProportion + (delta.x - delta.y) / sensitivity);
+ setValue (proportionOfLengthToValue (dragProportion), juce::sendNotificationSync);
+ lastDrag = e.position;
+}
+
+void Knob::mouseUp (const juce::MouseEvent&) { drag.reset(); }
+
+void Knob::mouseDoubleClick (const juce::MouseEvent& e)
+{
+ if (! e.mods.isAltDown() && ! e.mods.isPopupMenu())
+ beginEntry();
+}
+
+void Knob::cancelInteraction()
+{
+ drag.reset();
+ finishEntry (false);
+}
+
+void Knob::enablementChanged()
+{
+ if (! isEnabled())
+ cancelInteraction();
+ repaint();
+}
+
+bool Knob::keyPressed (const juce::KeyPress& key)
+{
+ if (! isEnabled())
+ return false;
+ if (key == juce::KeyPress::returnKey)
+ {
+ beginEntry();
+ return true;
+ }
+
+ const auto code = key.getKeyCode();
+ if (code == juce::KeyPress::upKey || code == juce::KeyPress::rightKey
+ || code == juce::KeyPress::downKey || code == juce::KeyPress::leftKey)
+ {
+ auto step = keyboardStep;
+ if (step <= 0.0)
+ step = getInterval() > 0.0 ? getInterval() : (getMaximum() - getMinimum()) / 100.0;
+ const auto direction = code == juce::KeyPress::upKey || code == juce::KeyPress::rightKey ? 1 : -1;
+ const auto fine = key.getModifiers().isShiftDown() && getInterval() <= 0.0 ? 0.1 : 1.0;
+ setWithGesture (getValue() + direction * step * fine);
+ return true;
+ }
+ if (code == juce::KeyPress::homeKey)
+ {
+ setWithGesture (defaultValue());
+ return true;
+ }
+ return juce::Slider::keyPressed (key);
+}
+
+void Knob::beginEntry()
+{
+ if (! isEnabled())
+ return;
+ drag.reset();
+ entry.setText (getTextFromValue (getValue()), false);
+ entry.setVisible (true);
+ entry.grabKeyboardFocus();
+ entry.selectAll();
+ repaint();
+}
+
+void Knob::finishEntry (bool commit)
+{
+ if (! entry.isVisible())
+ return;
+ const auto typed = entry.getText().trim();
+ entry.setVisible (false);
+
+ if (commit)
+ {
+ // Accept one finite number plus an optional matching unit. Do not let a
+ // permissive parser turn malformed input into zero.
+ auto number = typed;
+ const auto unit = parameter.getLabel();
+ if (unit.isNotEmpty() && number.endsWithIgnoreCase (unit))
+ number = number.dropLastCharacters (unit.length()).trimEnd();
+ const auto utf8 = number.toRawUTF8();
+ char* end = nullptr;
+ const auto value = std::strtod (utf8, &end);
+ if (number.containsOnly ("0123456789.+-") && end != utf8 && *end == '\0' && std::isfinite (value))
+ setWithGesture (parameter.convertFrom0to1 (parameter.getValueForText (number)));
+ grabKeyboardFocus();
+ }
+ repaint();
+}
+
+void Knob::showMenu()
+{
+ juce::PopupMenu menu;
+ const juce::Component::SafePointer safe (this);
+ menu.addItem ("Enter value...", [safe] { if (safe != nullptr) safe->beginEntry(); });
+ menu.addItem ("Reset to default", [safe]
+ {
+ if (safe != nullptr)
+ safe->setWithGesture (safe->defaultValue());
+ });
+ if (auto* editor = findParentComponentOfClass())
+ if (auto* host = editor->getHostContext())
+ if (auto hostMenu = host->getContextMenuForParameter (¶meter))
+ menu.addSubMenu ("Host", hostMenu->getEquivalentPopupMenu());
+ menu.showMenuAsync (juce::PopupMenu::Options().withTargetComponent (this));
+}
+} // namespace affine
diff --git a/libs/affine-ui/affine_ui/components/affine_Knob.h b/libs/affine-ui/affine_ui/components/affine_Knob.h
new file mode 100644
index 0000000..779e734
--- /dev/null
+++ b/libs/affine-ui/affine_ui/components/affine_Knob.h
@@ -0,0 +1,98 @@
+#pragma once
+
+namespace affine
+{
+/**
+ The family rotary control: a rendered knob inside a ring of LEDs lit up to
+ its value, with a calibrated printed scale, a label and a glass readout.
+
+ Interaction contract (identical in every product):
+ - drag anywhere on the control; up or right increases; Shift for fine;
+ - double-click or Return for exact entry; Return commits, Escape cancels,
+ malformed text never changes the value;
+ - Alt/Option-click or Home resets to the default;
+ - arrow keys step; right-click opens the parameter menu, including host items.
+ Every edit is one delimited host gesture.
+*/
+class Knob : public juce::Slider
+{
+public:
+ Knob (juce::AudioProcessorValueTreeState&, const juce::String& parameterID, const juce::String& help = {});
+ ~Knob() override;
+
+ juce::RangedAudioParameter& parameter;
+
+ void setTheme (const Theme&);
+ void setDiameter (float logicalDiameter);
+ float getDiameter() const noexcept { return diameter; }
+ void setLabel (const juce::String&);
+
+ /** Printed scale numbers at plain parameter values, placed through the parameter's own mapping. */
+ void setScale (std::vector plainValues, std::function formatter = {});
+
+ /** Step for arrow keys, in plain units. Defaults to the parameter interval or 1% of the range. */
+ void setKeyboardStep (double plainStep) { keyboardStep = plainStep; }
+
+ /** Inactive but editable: the control does not currently affect the sound. */
+ void setInactive (bool);
+ bool isInactive() const noexcept { return inactive; }
+
+ /** A lamp beside the label that reports whether the control currently acts on the sound. */
+ void setShowsActivityLamp (bool shouldShow);
+
+ juce::Point getPreferredSize() const;
+ /** Preferred bounds, in the parent, that put the knob's axis at `knobCentre`. */
+ juce::Rectangle getBoundsForCentre (juce::Point knobCentre) const;
+ juce::Point getKnobCentre() const;
+
+ void beginEntry();
+ void cancelInteraction();
+
+ void paint (juce::Graphics&) override;
+ void resized() override;
+ void mouseDown (const juce::MouseEvent&) override;
+ void mouseDrag (const juce::MouseEvent&) override;
+ void mouseUp (const juce::MouseEvent&) override;
+ void mouseDoubleClick (const juce::MouseEvent&) override;
+ bool keyPressed (const juce::KeyPress&) override;
+ void focusGained (FocusChangeType) override { repaint(); }
+ void focusLost (FocusChangeType) override { repaint(); }
+ void enablementChanged() override;
+ void mouseEnter (const juce::MouseEvent&) override { repaint(); }
+ void mouseExit (const juce::MouseEvent&) override { repaint(); }
+
+private:
+ struct Geometry
+ {
+ juce::Point centre;
+ float radius = 0.0f;
+ juce::Rectangle label, readout;
+ };
+
+ Geometry getGeometry() const;
+ void finishEntry (bool commit);
+ void setWithGesture (double plainValue);
+ void showMenu();
+ double defaultValue() const;
+ void paintScale (juce::Graphics&, const Geometry&);
+ void paintReadout (juce::Graphics&, const Geometry&);
+
+ Theme theme;
+ KnobRenderer renderer;
+ render::GlowText valueGlow;
+ juce::String label;
+ float diameter = 64.0f;
+ std::vector scaleValues;
+ std::function scaleFormatter;
+ double keyboardStep = 0.0;
+ bool inactive = false, activityLamp = false;
+
+ juce::TextEditor entry;
+ juce::Point lastDrag;
+ double dragProportion = 0.0;
+ std::unique_ptr drag;
+ juce::AudioProcessorValueTreeState::SliderAttachment attachment;
+
+ JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (Knob)
+};
+} // namespace affine
diff --git a/libs/affine-ui/affine_ui/components/affine_Screen.cpp b/libs/affine-ui/affine_ui/components/affine_Screen.cpp
new file mode 100644
index 0000000..e07b55e
--- /dev/null
+++ b/libs/affine-ui/affine_ui/components/affine_Screen.cpp
@@ -0,0 +1,27 @@
+namespace affine::screen
+{
+juce::Colour lit (juce::Colour screen, float level)
+{
+ return screen.interpolatedWith (juce::Colours::white, 0.2f).withMultipliedAlpha (level);
+}
+
+void caption (juce::Graphics& g, const juce::String& text, juce::Rectangle area, juce::Colour screen,
+ juce::Justification justification, float alpha)
+{
+ g.setColour (screen.withAlpha (alpha));
+ g.setFont (fonts::label (12.0f, 0.22f));
+ g.drawText (text, area, justification, false);
+}
+
+void segments (juce::Graphics& g, juce::Rectangle area, int count, float fraction,
+ const std::function& colourFor)
+{
+ const auto pitch = area.getWidth() / static_cast (count);
+ for (int i = 0; i < count; ++i)
+ {
+ const auto position = (static_cast (i) + 0.5f) / static_cast (count);
+ g.setColour (colourFor (position, position <= fraction));
+ g.fillRect (area.getX() + static_cast (i) * pitch, area.getY(), pitch - 1.0f, area.getHeight());
+ }
+}
+} // namespace affine::screen
diff --git a/libs/affine-ui/affine_ui/components/affine_Screen.h b/libs/affine-ui/affine_ui/components/affine_Screen.h
new file mode 100644
index 0000000..e52bee5
--- /dev/null
+++ b/libs/affine-ui/affine_ui/components/affine_Screen.h
@@ -0,0 +1,18 @@
+#pragma once
+
+namespace affine::screen
+{
+/** The screen colour lifted towards white, for lit numerals, traces and markers. */
+juce::Colour lit (juce::Colour screen, float level = 1.0f);
+
+/** A caption in the screen colour: a display's title or the name of a reading. */
+void caption (juce::Graphics&, const juce::String& text, juce::Rectangle area, juce::Colour screen,
+ juce::Justification = juce::Justification::centredLeft, float alpha = 0.85f);
+
+/**
+ A segmented bar across `area`. A cell lights when `fraction` reaches its centre;
+ `colourFor` gives each cell's colour from its centre position (0 to 1) and whether it is lit.
+*/
+void segments (juce::Graphics&, juce::Rectangle area, int count, float fraction,
+ const std::function& colourFor);
+} // namespace affine::screen
diff --git a/libs/affine-ui/affine_ui/render/affine_Glow.cpp b/libs/affine-ui/affine_ui/render/affine_Glow.cpp
new file mode 100644
index 0000000..4446242
--- /dev/null
+++ b/libs/affine-ui/affine_ui/render/affine_Glow.cpp
@@ -0,0 +1,133 @@
+namespace affine::render
+{
+namespace
+{
+void boxPass (const juce::uint8* src, juce::uint8* dst, int count, int stride, int radius)
+{
+ if (radius <= 0)
+ {
+ for (int i = 0; i < count; ++i)
+ dst[i * stride] = src[i * stride];
+ return;
+ }
+
+ const auto window = radius * 2 + 1;
+ int sum = 0;
+ for (int i = -radius; i <= radius; ++i)
+ sum += src[juce::jlimit (0, count - 1, i) * stride];
+
+ for (int i = 0; i < count; ++i)
+ {
+ dst[i * stride] = static_cast ((sum + window / 2) / window);
+ const auto add = juce::jmin (count - 1, i + radius + 1);
+ const auto remove = juce::jmax (0, i - radius);
+ sum += src[add * stride] - src[remove * stride];
+ }
+}
+} // namespace
+
+void blurAlpha (juce::Image& image, float radiusPx)
+{
+ jassert (image.getFormat() == juce::Image::SingleChannel);
+ const auto width = image.getWidth(), height = image.getHeight();
+ if (width == 0 || height == 0 || radiusPx < 0.5f)
+ return;
+
+ // Three box passes of this width approximate a Gaussian with sigma ~= radius / 2.
+ const auto box = juce::jmax (1, juce::roundToInt (radiusPx * 0.58f));
+ juce::Image::BitmapData data (image, juce::Image::BitmapData::readWrite);
+ std::vector line (static_cast (juce::jmax (width, height)));
+ std::vector temp (line.size());
+
+ for (int pass = 0; pass < 3; ++pass)
+ {
+ for (int y = 0; y < height; ++y)
+ {
+ auto* row = data.getLinePointer (y);
+ for (int x = 0; x < width; ++x)
+ line[static_cast (x)] = row[x * data.pixelStride];
+ boxPass (line.data(), temp.data(), width, 1, box);
+ for (int x = 0; x < width; ++x)
+ row[x * data.pixelStride] = temp[static_cast (x)];
+ }
+
+ for (int x = 0; x < width; ++x)
+ {
+ for (int y = 0; y < height; ++y)
+ line[static_cast (y)] = *data.getPixelPointer (x, y);
+ boxPass (line.data(), temp.data(), height, 1, box);
+ for (int y = 0; y < height; ++y)
+ *data.getPixelPointer (x, y) = temp[static_cast (y)];
+ }
+ }
+}
+
+void GlowLayer::draw (juce::Graphics& g, const juce::String& key, juce::Rectangle area, juce::Colour colour,
+ float glowRadius, float glowStrength, const Painter& painter)
+{
+ const auto scale = juce::jmax (0.5f, g.getInternalContext().getPhysicalPixelScaleFactor());
+ const auto localArea = area.withZeroOrigin();
+
+ if (key != cachedKey || localArea != cachedArea || ! juce::approximatelyEqual (scale, cachedScale)
+ || ! juce::approximatelyEqual (glowRadius, cachedRadius) || ! crisp.isValid())
+ {
+ cachedKey = key;
+ cachedArea = localArea;
+ cachedScale = scale;
+ cachedRadius = glowRadius;
+ margin = juce::roundToInt (glowRadius * 2.0f * scale) + 2;
+
+ const auto w = juce::roundToInt (area.getWidth() * scale) + margin * 2;
+ const auto h = juce::roundToInt (area.getHeight() * scale) + margin * 2;
+ crisp = juce::Image (juce::Image::SingleChannel, juce::jmax (1, w), juce::jmax (1, h), true);
+ {
+ juce::Graphics cg (crisp);
+ cg.addTransform (juce::AffineTransform::scale (scale).translated (static_cast (margin), static_cast (margin)));
+ cg.setColour (juce::Colours::white);
+ painter (cg);
+ }
+ glow = crisp.createCopy();
+ blurAlpha (glow, glowRadius * scale);
+ }
+
+ const auto transform = juce::AffineTransform::translation (static_cast (-margin), static_cast (-margin))
+ .scaled (1.0f / scale)
+ .translated (area.getX(), area.getY());
+ g.setColour (colour.withMultipliedAlpha (juce::jlimit (0.0f, 1.0f, glowStrength)));
+ g.drawImageTransformed (glow, transform, true);
+ if (glowStrength > 1.0f)
+ {
+ g.setColour (colour.withMultipliedAlpha (juce::jlimit (0.0f, 1.0f, glowStrength - 1.0f)));
+ g.drawImageTransformed (glow, transform, true);
+ }
+ g.setColour (colour.interpolatedWith (juce::Colours::white, 0.25f));
+ g.drawImageTransformed (crisp, transform, true);
+}
+
+void GlowText::draw (juce::Graphics& g, const juce::String& text, const juce::Font& font, juce::Rectangle area,
+ juce::Justification justification, juce::Colour colour, float glowRadius, float glowStrength)
+{
+ if (text.isEmpty())
+ return;
+ const auto key = text + "|" + font.getTypefaceName() + "|" + juce::String (font.getHeight(), 2)
+ + "|" + juce::String (justification.getFlags());
+ const auto local = area.withZeroOrigin();
+ layer.draw (g, key, area, colour, glowRadius, glowStrength, [&] (juce::Graphics& lg)
+ {
+ lg.setFont (font);
+ lg.drawText (text, local, justification, false);
+ });
+}
+
+void halo (juce::Graphics& g, juce::Point centre, float radius, juce::Colour colour, float intensity)
+{
+ if (intensity <= 0.0f)
+ return;
+ juce::ColourGradient gradient (colour.withMultipliedAlpha (intensity), centre,
+ colour.withAlpha (0.0f), centre.translated (radius, 0.0f), true);
+ gradient.addColour (0.25, colour.withMultipliedAlpha (intensity * 0.55f));
+ gradient.addColour (0.55, colour.withMultipliedAlpha (intensity * 0.16f));
+ g.setGradientFill (gradient);
+ g.fillEllipse (juce::Rectangle (radius * 2.0f, radius * 2.0f).withCentre (centre));
+}
+} // namespace affine::render
diff --git a/libs/affine-ui/affine_ui/render/affine_Glow.h b/libs/affine-ui/affine_ui/render/affine_Glow.h
new file mode 100644
index 0000000..ebd10e2
--- /dev/null
+++ b/libs/affine-ui/affine_ui/render/affine_Glow.h
@@ -0,0 +1,42 @@
+#pragma once
+
+namespace affine::render
+{
+/** Three-pass box blur approximating a Gaussian, in place, on a single-channel image. */
+void blurAlpha (juce::Image& singleChannel, float radiusPx);
+
+/**
+ Caches light-emitting artwork (display segments, traces) with a soft bloom.
+ The painter draws white shapes in coordinates local to `area`; the layer is
+ rebuilt only when the key, the bounds or the physical scale change.
+*/
+class GlowLayer
+{
+public:
+ using Painter = std::function;
+
+ void draw (juce::Graphics&, const juce::String& key, juce::Rectangle area, juce::Colour colour,
+ float glowRadius, float glowStrength, const Painter&);
+
+private:
+ juce::String cachedKey;
+ juce::Rectangle cachedArea;
+ float cachedScale = 0.0f, cachedRadius = 0.0f;
+ juce::Image crisp, glow;
+ int margin = 0;
+};
+
+/** Light-emitting text, e.g. a display readout. */
+class GlowText
+{
+public:
+ void draw (juce::Graphics&, const juce::String& text, const juce::Font&, juce::Rectangle area,
+ juce::Justification, juce::Colour colour, float glowRadius = 3.0f, float glowStrength = 0.9f);
+
+private:
+ GlowLayer layer;
+};
+
+/** Paints a circular soft light, e.g. the halo of a lit LED, without blurring. */
+void halo (juce::Graphics&, juce::Point centre, float radius, juce::Colour colour, float intensity);
+} // namespace affine::render
diff --git a/libs/affine-ui/affine_ui/render/affine_KnobRenderer.cpp b/libs/affine-ui/affine_ui/render/affine_KnobRenderer.cpp
new file mode 100644
index 0000000..603bcd8
--- /dev/null
+++ b/libs/affine-ui/affine_ui/render/affine_KnobRenderer.cpp
@@ -0,0 +1,267 @@
+namespace affine
+{
+namespace
+{
+using namespace shading;
+
+enum class Zone { cap, chamfer, grip, skirt };
+
+struct Surface
+{
+ V3 normal;
+ Zone zone;
+ float occlusion;
+};
+
+// Radii of the knob's zones as fractions of its radius.
+struct Profile
+{
+ float cap, chamfer, grip, rim, dome;
+};
+
+Profile profileFor (const KnobFinish& finish) noexcept
+{
+ return { finish.capRatio, finish.capRatio + 0.036f, finish.gripRatio, 0.950f, finish.capDome };
+}
+
+const V3 keyLight = V3 { -0.30f, -0.78f, 0.62f }.normalised();
+const V3 fillLight = V3 { 0.55f, 0.35f, 0.76f }.normalised();
+const V3 softbox = V3 { -0.16f, -0.58f, 0.80f }.normalised();
+const V3 halfway = (keyLight + V3 { 0.0f, 0.0f, 1.0f }).normalised();
+
+// The studio seen in a reflection: one large softbox above the player, a dim
+// room elsewhere. Every product shares this environment.
+float environment (V3 r) noexcept
+{
+ const auto box = smoothstep (0.70f, 0.985f, r.dot (softbox));
+ const auto sky = saturate (-r.y * 0.85f + 0.08f);
+ return 0.030f + 1.35f * box + 0.20f * sky * sky;
+}
+
+Surface surfaceAt (float r, float phi, float gripAngle, int ridges, float ridgeDepth, bool shaped, const Profile& p) noexcept
+{
+ const auto c = std::cos (phi), s = std::sin (phi);
+ const V3 radial { c, s, 0.0f }, tangent { -s, c, 0.0f }, up { 0.0f, 0.0f, 1.0f };
+ float slope = 0.0f, occlusion = 1.0f;
+ Zone zone = Zone::cap;
+
+ if (r < p.cap)
+ {
+ // A slightly domed face keeps the reflection alive across the cap.
+ slope = p.dome * r / p.cap;
+ }
+ else if (r < p.chamfer)
+ {
+ zone = Zone::chamfer;
+ slope = mix (0.55f, 0.95f, (r - p.cap) / (p.chamfer - p.cap));
+ }
+ else if (r < p.grip)
+ {
+ zone = Zone::grip;
+ slope = 1.00f;
+ occlusion = mix (0.55f, 1.0f, smoothstep (p.grip, p.chamfer + 0.04f, r));
+ }
+ else if (r < p.rim)
+ {
+ zone = Zone::skirt;
+ slope = 0.22f;
+ occlusion = mix (0.30f, 1.0f, smoothstep (p.grip, p.grip + 0.07f, r));
+ }
+ else
+ {
+ zone = Zone::skirt;
+ const auto t = saturate ((r - p.rim) / (1.0f - p.rim));
+ slope = mix (0.22f, 1.35f, t * t);
+ }
+
+ auto normal = radial * std::sin (slope) + up * std::cos (slope);
+
+ if (zone == Zone::grip && shaped && ridgeDepth > 0.0f)
+ {
+ const auto phase = (phi - gripAngle) * static_cast (ridges);
+ normal = (normal + tangent * (ridgeDepth * std::sin (phase))).normalised();
+ occlusion *= 1.0f - 0.61f * ridgeDepth * (0.5f - 0.5f * std::cos (phase));
+ }
+
+ return { normal, zone, occlusion };
+}
+
+V3 shadeMaterial (KnobFinish::Material material, juce::Colour colour, const Surface& surface, float r, float phi) noexcept
+{
+ const auto n = surface.normal;
+ const auto reflected = reflect ({ 0.0f, 0.0f, -1.0f }, n);
+ const auto key = saturate (n.dot (keyLight));
+ const auto fill = saturate (n.dot (fillLight)) * 0.18f;
+ const auto specular = saturate (n.dot (halfway));
+ const auto face = surface.zone == Zone::cap;
+ const V3 white { 1.0f, 1.0f, 1.0f };
+ const auto base = linear (colour);
+
+ switch (material)
+ {
+ case KnobFinish::Material::spunAluminium:
+ {
+ if (face)
+ {
+ const V3 t { -std::sin (phi), std::cos (phi), 0.0f };
+ const auto th = t.dot (halfway);
+ const auto kajiyaKay = std::sqrt (std::max (0.0f, 1.0f - th * th));
+ // Lathe grooves: fine concentric rings with slow variation in depth.
+ const auto grooves = 0.72f + 0.56f * fbm (r * 310.0f, 3.7f, 3, 11);
+ const auto sheen = std::pow (kajiyaKay, 64.0f) * grooves;
+ const auto broad = std::pow (kajiyaKay, 9.0f) * 0.22f;
+ const auto lit = base * (0.05f + 0.28f * key + fill) + base * (environment (reflected) * 0.42f)
+ + base * ((sheen * 0.95f + broad) * grooves);
+ // A soft hot spot where the softbox reflects near the centre.
+ const auto hot = std::exp (-std::pow ((r - 0.18f) / 0.30f, 2.0f)) * 0.05f;
+ return (lit + white * hot) * surface.occlusion;
+ }
+ const auto bright = base * (0.10f + 0.30f * key + fill) + base * (environment (reflected) * 0.95f)
+ + white * (std::pow (specular, 90.0f) * 1.6f);
+ return bright * surface.occlusion;
+ }
+ case KnobFinish::Material::anodised:
+ default:
+ {
+ if (surface.zone == Zone::skirt)
+ return (base * (0.30f + 1.6f * key + fill) + white * (environment (reflected) * 0.030f)
+ + white * (std::pow (specular, 14.0f) * 0.07f)) * surface.occlusion;
+ return (base * (0.35f + 2.2f * key + fill) + white * (environment (reflected) * 0.070f)
+ + white * (std::pow (specular, 18.0f) * 0.20f)) * surface.occlusion;
+ }
+ }
+}
+
+V3 shade (const Surface& surface, float r, float phi, const KnobFinish& finish) noexcept
+{
+ const auto onCap = surface.zone == Zone::cap || surface.zone == Zone::chamfer;
+ return shadeMaterial (onCap ? finish.capMaterial : finish.bodyMaterial,
+ onCap ? finish.cap : finish.body, surface, r, phi);
+}
+
+template
+void renderSupersampled (juce::Image& image, int size, int samples, juce::Rectangle area, Fn&& sample)
+{
+ juce::Image::BitmapData data (image, juce::Image::BitmapData::writeOnly);
+ const auto radius = static_cast (size) * 0.5f;
+ const auto inv = 1.0f / static_cast (samples * samples);
+
+ for (int y = area.getY(); y < area.getBottom(); ++y)
+ {
+ for (int x = area.getX(); x < area.getRight(); ++x)
+ {
+ V3 colour;
+ float alpha = 0.0f;
+
+ for (int sy = 0; sy < samples; ++sy)
+ {
+ for (int sx = 0; sx < samples; ++sx)
+ {
+ const auto px = (static_cast (x) + (static_cast (sx) + 0.5f) / static_cast (samples)) - radius;
+ const auto py = (static_cast (y) + (static_cast (sy) + 0.5f) / static_cast (samples)) - radius;
+ float weight = 0.0f;
+ const auto c = sample (px / radius, py / radius, weight);
+ colour += c * weight;
+ alpha += weight;
+ }
+ }
+
+ if (alpha > 0.0f)
+ writePixel (data, x, y, colour * (1.0f / alpha), alpha * inv);
+ else
+ reinterpret_cast (data.getPixelPointer (x, y))->setARGB (0, 0, 0, 0);
+ }
+ }
+}
+} // namespace
+
+void KnobRenderer::prepare (int diameterPx)
+{
+ diameterPx = juce::jmax (8, diameterPx);
+ if (diameterPx == diameter)
+ return;
+
+ diameter = diameterPx;
+ shadowMargin = juce::roundToInt (static_cast (diameter) * 0.26f) + 2;
+ // Keep ridge pitch roughly constant in physical pixels, within a believable range.
+ ridges = finish.ridges > 0 ? finish.ridges
+ : juce::jlimit (24, 60, juce::roundToInt (static_cast (diameter) * 0.36f));
+ gripAngle = std::numeric_limits::quiet_NaN();
+ renderBody();
+ renderShadow();
+}
+
+void KnobRenderer::renderBody()
+{
+ body = juce::Image (juce::Image::ARGB, diameter, diameter, true);
+ const auto profile = profileFor (finish);
+ renderSupersampled (body, diameter, 3, body.getBounds(), [this, profile] (float x, float y, float& weight)
+ {
+ const auto r = std::sqrt (x * x + y * y);
+ if (r > 1.0f)
+ return V3 {};
+ weight = 1.0f;
+ const auto phi = std::atan2 (y, x);
+ return shade (surfaceAt (r, phi, 0.0f, ridges, finish.ridgeDepth, false, profile), r, phi, finish);
+ });
+}
+
+void KnobRenderer::renderShadow()
+{
+ const auto size = diameter + shadowMargin * 2;
+ shadow = juce::Image (juce::Image::ARGB, size, size, true);
+ juce::Image::BitmapData data (shadow, juce::Image::BitmapData::writeOnly);
+ const auto radius = static_cast (diameter) * 0.5f;
+ const auto centre = static_cast (size) * 0.5f;
+
+ for (int y = 0; y < size; ++y)
+ {
+ for (int x = 0; x < size; ++x)
+ {
+ const auto dx = static_cast (x) + 0.5f - centre;
+ const auto dy = static_cast (y) + 0.5f - centre;
+ // Soft key-light shadow, cast down and slightly right.
+ const auto drop = std::hypot (dx - radius * 0.05f, dy - radius * 0.16f) / radius;
+ const auto soft = 1.0f - smoothstep (0.70f, 1.42f, drop);
+ // Tight contact shadow where the skirt meets the panel.
+ const auto contact = std::hypot (dx, dy - radius * 0.035f) / radius;
+ const auto tight = 1.0f - smoothstep (0.93f, 1.07f, contact);
+ const auto alpha = saturate (soft * 0.58f + tight * 0.62f - soft * tight * 0.58f * 0.62f);
+ auto* pixel = reinterpret_cast (data.getPixelPointer (x, y));
+ pixel->setARGB (static_cast (juce::roundToInt (alpha * 255.0f)), 0, 0, 0);
+ }
+ }
+}
+
+const juce::Image& KnobRenderer::getGrip (float angle)
+{
+ if (! juce::exactlyEqual (angle, gripAngle))
+ renderGrip (angle);
+ return grip;
+}
+
+void KnobRenderer::renderGrip (float angle)
+{
+ gripAngle = angle;
+ if (! grip.isValid() || grip.getWidth() != diameter)
+ grip = juce::Image (juce::Image::ARGB, diameter, diameter, true);
+
+ const auto radius = static_cast (diameter) * 0.5f;
+ const auto overlap = 1.5f / radius;
+ const auto profile = profileFor (finish);
+ const auto inner = profile.chamfer - overlap, outer = profile.grip + overlap;
+ // atan2 has 0 pointing right; knob angles have 0 pointing up.
+ const auto ridgeAngle = angle - juce::MathConstants::halfPi;
+
+ renderSupersampled (grip, diameter, 2, grip.getBounds(), [&] (float x, float y, float& weight)
+ {
+ const auto r = std::sqrt (x * x + y * y);
+ if (r < inner || r > outer)
+ return V3 {};
+ // Fade the band edges over matching body pixels so the seam is invisible.
+ weight = smoothstep (inner, inner + overlap, r) * (1.0f - smoothstep (outer - overlap, outer, r));
+ const auto phi = std::atan2 (y, x);
+ return shade (surfaceAt (r, phi, ridgeAngle, ridges, finish.ridgeDepth, true, profile), r, phi, finish);
+ });
+}
+} // namespace affine
diff --git a/libs/affine-ui/affine_ui/render/affine_KnobRenderer.h b/libs/affine-ui/affine_ui/render/affine_KnobRenderer.h
new file mode 100644
index 0000000..ffe8e69
--- /dev/null
+++ b/libs/affine-ui/affine_ui/render/affine_KnobRenderer.h
@@ -0,0 +1,64 @@
+#pragma once
+
+namespace affine
+{
+/** Materials and proportions of the family knob. */
+struct KnobFinish
+{
+ enum class Material
+ {
+ spunAluminium, // lathe-turned metal with an anisotropic highlight
+ anodised // satin dyed metal
+ };
+
+ juce::Colour cap { 0xff34373c };
+ juce::Colour body { 0xff141518 };
+ juce::Colour pointer { 0xfff2f4f7 }; // paint-filled engraving on the cap
+ juce::Colour index { 0xfff2f4f7 }; // printed index on the skirt
+ Material capMaterial = Material::spunAluminium;
+ Material bodyMaterial = Material::anodised;
+ float capRatio = 0.60f; // cap radius as a fraction of the knob radius
+ float gripRatio = 0.80f; // outer radius of the knurled or fluted grip
+ float capDome = 0.06f; // slope of the cap at its edge, in radians
+ int ridges = 0; // grip ridges; 0 picks a pitch for the size
+ float ridgeDepth = 0.62f; // 0 gives a smooth grip
+};
+
+/**
+ Renders the Affine knob with a fixed studio light, so highlights stay put
+ while the knob turns: exactly what real hardware does.
+
+ The rotation-invariant body and the shadow are rendered once per physical
+ size. Only the grip band is re-shaded when the angle changes.
+*/
+class KnobRenderer
+{
+public:
+ explicit KnobRenderer (KnobFinish finishToUse = {}) : finish (finishToUse) {}
+
+ /** Rebuilds the caches if the physical diameter changed. */
+ void prepare (int diameterPx);
+
+ int getDiameter() const noexcept { return diameter; }
+ const juce::Image& getBody() const noexcept { return body; }
+
+ /** Shadow image; its top-left sits at (-getShadowMargin(), -getShadowMargin()) from the body. */
+ const juce::Image& getShadow() const noexcept { return shadow; }
+ int getShadowMargin() const noexcept { return shadowMargin; }
+
+ /** The grip band for a knob angle in radians (0 = pointing up, clockwise positive). */
+ const juce::Image& getGrip (float angle);
+
+ const KnobFinish& getFinish() const noexcept { return finish; }
+
+private:
+ void renderBody();
+ void renderShadow();
+ void renderGrip (float angle);
+
+ KnobFinish finish;
+ int diameter = 0, shadowMargin = 0, ridges = 36;
+ float gripAngle = std::numeric_limits::quiet_NaN();
+ juce::Image body, shadow, grip;
+};
+} // namespace affine
diff --git a/libs/affine-ui/affine_ui/render/affine_PanelRenderer.cpp b/libs/affine-ui/affine_ui/render/affine_PanelRenderer.cpp
new file mode 100644
index 0000000..d4dc7d7
--- /dev/null
+++ b/libs/affine-ui/affine_ui/render/affine_PanelRenderer.cpp
@@ -0,0 +1,95 @@
+namespace affine::render
+{
+using namespace shading;
+
+namespace
+{
+V3 powderPixel (const PanelFinish& finish, V3 base, float u, float v, float lx, float ly, int x, int y) noexcept
+{
+ const auto vertical = mix (1.10f, 0.84f, smoothstep (-0.1f, 1.05f, v));
+ const auto speckle = (hash (x, y, 41) - 0.5f) * finish.grain + (valueNoise (lx * 2.2f, ly * 2.2f, 43) - 0.5f) * finish.grain * 0.8f;
+ const auto mottle = (fbm (lx / 70.0f, ly / 70.0f, 3, 47) - 0.5f) * finish.mottle;
+ const auto sheenX = (u - 0.35f) / 0.7f, sheenY = (v + 0.2f) / 0.6f;
+ const auto sheen = std::exp (-(sheenX * sheenX + sheenY * sheenY)) * finish.sheen * 0.02f;
+ return base * (vertical * (1.0f + speckle + mottle)) + V3 { sheen, sheen, sheen };
+}
+} // namespace
+
+juce::Image faceplate (juce::Rectangle logicalBounds, float scale, const PanelFinish& finish)
+{
+ const auto width = juce::jmax (1, juce::roundToInt (static_cast (logicalBounds.getWidth()) * scale));
+ const auto height = juce::jmax (1, juce::roundToInt (static_cast (logicalBounds.getHeight()) * scale));
+ juce::Image image (juce::Image::ARGB, width, height, false);
+ juce::Image::BitmapData data (image, juce::Image::BitmapData::writeOnly);
+
+ const auto base = linear (finish.base);
+ const auto inverseScale = 1.0f / scale;
+
+ for (int y = 0; y < height; ++y)
+ {
+ const auto v = (static_cast (y) + 0.5f) / static_cast (height);
+ const auto ly = (static_cast (y) + 0.5f) * inverseScale;
+
+ for (int x = 0; x < width; ++x)
+ {
+ const auto u = (static_cast (x) + 0.5f) / static_cast (width);
+ const auto lx = (static_cast (x) + 0.5f) * inverseScale;
+ const auto colour = powderPixel (finish, base, u, v, lx, ly, x, y);
+ auto* pixel = reinterpret_cast (data.getPixelPointer (x, y));
+ pixel->setARGB (255,
+ static_cast (juce::roundToInt (toSrgb (colour.x) * 255.0f)),
+ static_cast (juce::roundToInt (toSrgb (colour.y) * 255.0f)),
+ static_cast (juce::roundToInt (toSrgb (colour.z) * 255.0f)));
+ }
+ }
+
+ return image;
+}
+
+void softShadow (juce::Graphics& g, juce::Rectangle area, float corner, float blur,
+ juce::Point offset, float opacity)
+{
+ juce::Path shape;
+ shape.addRoundedRectangle (area.translated (offset.x, offset.y), corner);
+ juce::DropShadow (juce::Colours::black.withAlpha (opacity), juce::jmax (1, juce::roundToInt (blur)), {})
+ .drawForPath (g, shape);
+}
+
+void recess (juce::Graphics& g, juce::Rectangle area, float corner, float depth)
+{
+ // Lit lower lip of the cut: the light comes from above.
+ g.setColour (juce::Colours::white.withAlpha (0.12f));
+ g.drawRoundedRectangle (area.expanded (0.5f).translated (0.0f, 0.8f), corner + 0.5f, 1.0f);
+ g.setColour (juce::Colours::black.withAlpha (0.65f));
+ g.drawRoundedRectangle (area.expanded (0.5f), corner + 0.5f, 1.0f);
+
+ juce::Graphics::ScopedSaveState state (g);
+ juce::Path clip;
+ clip.addRoundedRectangle (area, corner);
+ g.reduceClipRegion (clip);
+
+ // Inner shadow cast by the upper wall of the opening.
+ const auto h = 10.0f * depth;
+ juce::ColourGradient top (juce::Colours::black.withAlpha (0.55f), area.getX(), area.getY(),
+ juce::Colours::transparentBlack, area.getX(), area.getY() + h, false);
+ g.setGradientFill (top);
+ g.fillRect (area.withHeight (h));
+ juce::ColourGradient left (juce::Colours::black.withAlpha (0.25f), area.getX(), area.getY(),
+ juce::Colours::transparentBlack, area.getX() + h * 0.6f, area.getY(), false);
+ g.setGradientFill (left);
+ g.fillRect (area.withWidth (h * 0.6f));
+}
+
+void screenGlass (juce::Graphics& g, juce::Rectangle area)
+{
+ softShadow (g, area, 6.0f, 6.0f, { 0.0f, 2.0f }, 0.6f);
+ g.setColour (juce::Colour (0xff0b0c0e));
+ g.fillRoundedRectangle (area.expanded (4.0f), 7.0f);
+ g.setColour (juce::Colours::white.withAlpha (0.07f));
+ g.drawRoundedRectangle (area.expanded (4.0f), 7.0f, 1.0f);
+ juce::ColourGradient glass (juce::Colour (0xff0a0d10), area.getX(), area.getY(),
+ juce::Colour (0xff040506), area.getX(), area.getBottom(), false);
+ g.setGradientFill (glass);
+ g.fillRoundedRectangle (area, 3.0f);
+}
+} // namespace affine::render
diff --git a/libs/affine-ui/affine_ui/render/affine_PanelRenderer.h b/libs/affine-ui/affine_ui/render/affine_PanelRenderer.h
new file mode 100644
index 0000000..52ad2ff
--- /dev/null
+++ b/libs/affine-ui/affine_ui/render/affine_PanelRenderer.h
@@ -0,0 +1,29 @@
+#pragma once
+
+namespace affine
+{
+/** The panel: a fine matte powder coat. */
+struct PanelFinish
+{
+ juce::Colour base { 0xff17181b };
+ float grain = 0.10f; // speckle of the powder
+ float mottle = 0.03f; // slow variation of the coat
+ float sheen = 0.5f; // soft reflection of the studio softbox
+};
+
+namespace render
+{
+/** Renders a panel of the given logical size at a physical pixel scale. Deterministic. */
+juce::Image faceplate (juce::Rectangle logicalBounds, float scale, const PanelFinish&);
+
+/** A soft, analytic shadow for a rounded rectangle, e.g. under a raised control. */
+void softShadow (juce::Graphics&, juce::Rectangle area, float corner, float blur,
+ juce::Point offset, float opacity);
+
+/** An opening cut into the panel: inner shadow at the top, a lit lip at the bottom. */
+void recess (juce::Graphics&, juce::Rectangle area, float corner, float depth = 1.0f);
+
+/** A display screen: thin bezel around dark glass. */
+void screenGlass (juce::Graphics&, juce::Rectangle area);
+} // namespace render
+} // namespace affine
diff --git a/libs/affine-ui/affine_ui/render/affine_Shading.h b/libs/affine-ui/affine_ui/render/affine_Shading.h
new file mode 100644
index 0000000..92ce117
--- /dev/null
+++ b/libs/affine-ui/affine_ui/render/affine_Shading.h
@@ -0,0 +1,100 @@
+#pragma once
+
+namespace affine::shading
+{
+// Small, allocation-free helpers for the offline-style renderers. Everything
+// here runs on the message thread while building cached layers.
+
+struct V3
+{
+ float x = 0.0f, y = 0.0f, z = 0.0f;
+
+ V3 operator+ (V3 o) const noexcept { return { x + o.x, y + o.y, z + o.z }; }
+ V3 operator- (V3 o) const noexcept { return { x - o.x, y - o.y, z - o.z }; }
+ V3 operator* (float s) const noexcept { return { x * s, y * s, z * s }; }
+ V3 operator* (V3 o) const noexcept { return { x * o.x, y * o.y, z * o.z }; }
+ V3& operator+= (V3 o) noexcept { x += o.x; y += o.y; z += o.z; return *this; }
+ float dot (V3 o) const noexcept { return x * o.x + y * o.y + z * o.z; }
+ float length() const noexcept { return std::sqrt (dot (*this)); }
+ V3 normalised() const noexcept
+ {
+ const auto l = length();
+ return l > 1.0e-9f ? *this * (1.0f / l) : V3 { 0.0f, 0.0f, 1.0f };
+ }
+};
+
+inline float saturate (float v) noexcept { return juce::jlimit (0.0f, 1.0f, v); }
+
+inline float smoothstep (float e0, float e1, float x) noexcept
+{
+ const auto t = saturate ((x - e0) / (e1 - e0));
+ return t * t * (3.0f - 2.0f * t);
+}
+
+inline float mix (float a, float b, float t) noexcept { return a + (b - a) * t; }
+inline V3 mix (V3 a, V3 b, float t) noexcept { return a + (b - a) * t; }
+
+inline float toLinear (float srgb) noexcept
+{
+ return srgb <= 0.04045f ? srgb / 12.92f : std::pow ((srgb + 0.055f) / 1.055f, 2.4f);
+}
+
+inline float toSrgb (float linear) noexcept
+{
+ linear = saturate (linear);
+ return linear <= 0.0031308f ? linear * 12.92f : 1.055f * std::pow (linear, 1.0f / 2.4f) - 0.055f;
+}
+
+inline V3 linear (juce::Colour c) noexcept
+{
+ return { toLinear (c.getFloatRed()), toLinear (c.getFloatGreen()), toLinear (c.getFloatBlue()) };
+}
+
+inline V3 reflect (V3 incident, V3 n) noexcept { return incident - n * (2.0f * incident.dot (n)); }
+
+// Deterministic integer hash noise: identical on every platform and run.
+inline float hash (int x, int y, int seed = 0) noexcept
+{
+ auto h = static_cast (x) * 374761393u + static_cast (y) * 668265263u
+ + static_cast (seed) * 2246822519u;
+ h = (h ^ (h >> 13)) * 1274126177u;
+ h ^= h >> 16;
+ return static_cast (h & 0xffffffu) / 16777215.0f;
+}
+
+inline float valueNoise (float x, float y, int seed = 0) noexcept
+{
+ const auto xi = static_cast (std::floor (x)), yi = static_cast (std::floor (y));
+ const auto fx = x - static_cast (xi), fy = y - static_cast (yi);
+ const auto sx = fx * fx * (3.0f - 2.0f * fx), sy = fy * fy * (3.0f - 2.0f * fy);
+ const auto a = hash (xi, yi, seed), b = hash (xi + 1, yi, seed);
+ const auto c = hash (xi, yi + 1, seed), d = hash (xi + 1, yi + 1, seed);
+ return mix (mix (a, b, sx), mix (c, d, sx), sy);
+}
+
+inline float fbm (float x, float y, int octaves, int seed = 0) noexcept
+{
+ float sum = 0.0f, amplitude = 0.5f, norm = 0.0f;
+ for (int i = 0; i < octaves; ++i)
+ {
+ sum += amplitude * valueNoise (x, y, seed + i * 31);
+ norm += amplitude;
+ x *= 2.03f;
+ y *= 2.03f;
+ amplitude *= 0.5f;
+ }
+ return sum / norm;
+}
+
+// Premultiplied ARGB writer for juce::Image::BitmapData in either pixel order.
+inline void writePixel (juce::Image::BitmapData& data, int x, int y, V3 linearRgb, float alpha) noexcept
+{
+ alpha = saturate (alpha);
+ const auto r = static_cast (juce::roundToInt (toSrgb (linearRgb.x) * alpha * 255.0f));
+ const auto g = static_cast (juce::roundToInt (toSrgb (linearRgb.y) * alpha * 255.0f));
+ const auto b = static_cast (juce::roundToInt (toSrgb (linearRgb.z) * alpha * 255.0f));
+ const auto a = static_cast (juce::roundToInt (alpha * 255.0f));
+ auto* pixel = reinterpret_cast (data.getPixelPointer (x, y));
+ pixel->setARGB (a, r, g, b);
+}
+} // namespace affine::shading
diff --git a/libs/affine-ui/affine_ui/theme/affine_Fonts.cpp b/libs/affine-ui/affine_ui/theme/affine_Fonts.cpp
new file mode 100644
index 0000000..0848f7c
--- /dev/null
+++ b/libs/affine-ui/affine_ui/theme/affine_Fonts.cpp
@@ -0,0 +1,46 @@
+#include "AffineFonts.h"
+
+namespace affine::fonts
+{
+namespace
+{
+juce::Typeface::Ptr load (const char* data, int size)
+{
+ return juce::Typeface::createSystemTypefaceFor (data, static_cast (size));
+}
+
+juce::Font make (const juce::Typeface::Ptr& typeface, float height, float tracking)
+{
+ return juce::Font (juce::FontOptions (typeface).withHeight (height).withKerningFactor (tracking));
+}
+
+const juce::Typeface::Ptr& semibold()
+{
+ static auto t = load (AffineFonts::BarlowCondensedSemiBold_ttf, AffineFonts::BarlowCondensedSemiBold_ttfSize);
+ return t;
+}
+
+const juce::Typeface::Ptr& regular()
+{
+ static auto t = load (AffineFonts::BarlowCondensedRegular_ttf, AffineFonts::BarlowCondensedRegular_ttfSize);
+ return t;
+}
+
+const juce::Typeface::Ptr& michroma()
+{
+ static auto t = load (AffineFonts::MichromaRegular_ttf, AffineFonts::MichromaRegular_ttfSize);
+ return t;
+}
+
+const juce::Typeface::Ptr& mono()
+{
+ static auto t = load (AffineFonts::ShareTechMonoRegular_ttf, AffineFonts::ShareTechMonoRegular_ttfSize);
+ return t;
+}
+} // namespace
+
+juce::Font label (float height, float tracking) { return make (semibold(), height, tracking); }
+juce::Font text (float height, float tracking) { return make (regular(), height, tracking); }
+juce::Font wordmark (float height, float tracking) { return make (michroma(), height, tracking); }
+juce::Font readout (float height, float tracking) { return make (mono(), height, tracking); }
+} // namespace affine::fonts
diff --git a/libs/affine-ui/affine_ui/theme/affine_Fonts.h b/libs/affine-ui/affine_ui/theme/affine_Fonts.h
new file mode 100644
index 0000000..832f30b
--- /dev/null
+++ b/libs/affine-ui/affine_ui/theme/affine_Fonts.h
@@ -0,0 +1,16 @@
+#pragma once
+
+namespace affine::fonts
+{
+/** Silkscreen labels: Barlow Condensed SemiBold. `tracking` is a kerning factor. */
+juce::Font label (float height, float tracking = 0.06f);
+
+/** Supporting copy: Barlow Condensed Regular. */
+juce::Font text (float height, float tracking = 0.0f);
+
+/** The Affine wordmark in the maker's mark: Michroma, a wide machined grotesque. */
+juce::Font wordmark (float height, float tracking = 0.08f);
+
+/** Readouts and screen numerals: Share Tech Mono. */
+juce::Font readout (float height, float tracking = 0.0f);
+} // namespace affine::fonts
diff --git a/libs/affine-ui/affine_ui/theme/affine_LookAndFeel.cpp b/libs/affine-ui/affine_ui/theme/affine_LookAndFeel.cpp
new file mode 100644
index 0000000..6a30609
--- /dev/null
+++ b/libs/affine-ui/affine_ui/theme/affine_LookAndFeel.cpp
@@ -0,0 +1,76 @@
+namespace affine
+{
+namespace
+{
+constexpr float tooltipWidth = 280.0f;
+
+juce::TextLayout tooltipLayout (const juce::String& text, juce::Colour colour)
+{
+ juce::AttributedString s;
+ s.setJustification (juce::Justification::topLeft);
+ s.append (text, fonts::text (15.0f), colour);
+ juce::TextLayout layout;
+ layout.createLayoutWithBalancedLineLengths (s, tooltipWidth);
+ return layout;
+}
+} // namespace
+
+LookAndFeel::LookAndFeel (const Theme& t) { setTheme (t); }
+
+void LookAndFeel::setTheme (const Theme& t)
+{
+ theme = t;
+ const auto& p = theme.palette;
+ const auto menu = juce::Colour (0xff121518);
+ setColour (juce::PopupMenu::backgroundColourId, menu);
+ setColour (juce::PopupMenu::textColourId, p.silkscreen);
+ setColour (juce::PopupMenu::headerTextColourId, p.silkscreenDim);
+ setColour (juce::PopupMenu::highlightedBackgroundColourId, p.accent.withAlpha (0.22f));
+ setColour (juce::PopupMenu::highlightedTextColourId, juce::Colours::white);
+ setColour (juce::TooltipWindow::backgroundColourId, menu);
+ setColour (juce::TooltipWindow::textColourId, p.silkscreen);
+ setColour (juce::TooltipWindow::outlineColourId, juce::Colours::white.withAlpha (0.12f));
+ setColour (juce::TextEditor::backgroundColourId, p.glass);
+ setColour (juce::TextEditor::textColourId, p.silkscreen);
+ setColour (juce::TextEditor::highlightColourId, p.accent.withAlpha (0.35f));
+ setColour (juce::TextEditor::outlineColourId, p.silkscreenDim);
+ setColour (juce::TextEditor::focusedOutlineColourId, p.accent);
+ setColour (juce::CaretComponent::caretColourId, p.accent);
+ setColour (juce::Label::textColourId, p.silkscreen);
+}
+
+juce::Font LookAndFeel::getPopupMenuFont() { return fonts::text (16.0f); }
+
+void LookAndFeel::drawPopupMenuBackground (juce::Graphics& g, int width, int height)
+{
+ const auto bounds = juce::Rectangle (static_cast (width), static_cast (height));
+ g.fillAll (findColour (juce::PopupMenu::backgroundColourId));
+ juce::ColourGradient sheen (juce::Colours::white.withAlpha (0.04f), 0.0f, 0.0f,
+ juce::Colours::transparentWhite, 0.0f, 40.0f, false);
+ g.setGradientFill (sheen);
+ g.fillRect (bounds.withHeight (40.0f));
+ g.setColour (juce::Colours::white.withAlpha (0.10f));
+ g.drawRect (bounds, 1.0f);
+}
+
+juce::Rectangle